Browse Source

Merge pull request #1063 from mnaamani/storage-node/improve-sign-and-send

Storage node/improve sign and send
Mokhtar Naamani 4 years ago
parent
commit
f48a9bccd9

+ 2 - 1
cli/package.json

@@ -42,7 +42,8 @@
     "typescript": "^3.8.3"
   },
   "engines": {
-    "node": ">=8.0.0"
+    "node": ">=12.18.0",
+    "yarn": "^1.22.0"
   },
   "files": [
     "/bin",

+ 4 - 0
package.json

@@ -42,5 +42,9 @@
       "pre-commit": "devops/git-hooks/pre-commit",
       "pre-push": "devops/git-hooks/pre-push"
     }
+  },
+  "engines": {
+    "node": ">=12.18.0",
+    "yarn": "^1.22.0"
   }
 }

+ 2 - 2
pioneer/package.json

@@ -2,8 +2,8 @@
   "version": "0.37.0-beta.63",
   "private": true,
   "engines": {
-    "node": ">=10.13.0",
-    "yarn": "^1.10.1"
+    "node": ">=12.18.0",
+    "yarn": "^1.22.0"
   },
   "homepage": ".",
   "name": "pioneer",

+ 2 - 2
storage-node/package.json

@@ -3,8 +3,8 @@
   "name": "storage-node",
   "version": "1.0.0",
   "engines": {
-    "node": ">=10.15.3",
-    "yarn": "^1.15.2"
+    "node": ">=12.18.0",
+    "yarn": "^1.22.0"
   },
   "homepage": "https://github.com/Joystream/joystream/",
   "bugs": {

+ 1 - 1
storage-node/packages/cli/package.json

@@ -24,7 +24,7 @@
     "linux"
   ],
   "engines": {
-    "node": ">=10.15.3"
+    "node": ">=12.18.0"
   },
   "scripts": {
     "test": "mocha 'dist/test/**/*.js'",

+ 6 - 1
storage-node/packages/cli/src/cli.ts

@@ -121,7 +121,12 @@ export async function main() {
   if (Object.prototype.hasOwnProperty.call(commands, command)) {
     // Command recognized
     const args = _.clone(cli.input).slice(1)
-    await commands[command](api, ...args)
+    try {
+      await commands[command](api, ...args)
+    } catch (err) {
+      console.error('Command Failed:', err)
+      process.exit(-1)
+    }
   } else {
     showUsageAndExit(`Command "${command}" not recognized.`)
   }

+ 4 - 4
storage-node/packages/cli/src/commands/dev.ts

@@ -45,14 +45,14 @@ const batchDispatchCalls = async (
 
   debug(`dispatching ${rawCalls.length} transactions.`)
 
-  await rawCalls
-    .map((call) => {
+  // promise.all to avoid unhandled promise rejection
+  return Promise.all(
+    rawCalls.map((call) => {
       const { methodName, sectionName, args } = call
       const tx = api.tx[sectionName][methodName](...args)
       return runtimeApi.signAndSend(senderAddress, tx)
     })
-    .reverse()
-    .shift()
+  )
 }
 
 // Dispatch pre-prepared calls to runtime to initialize the versioned store

+ 23 - 4
storage-node/packages/colossus/bin/cli.js

@@ -111,13 +111,13 @@ function startExpressApp(app, port) {
 
 // Start app
 function startAllServices({ store, api, port }) {
-  const app = require('../lib/app')(PROJECT_ROOT, store, api) // reduce falgs to only needed values
+  const app = require('../lib/app')(PROJECT_ROOT, store, api)
   return startExpressApp(app, port)
 }
 
 // Start discovery service app only
 function startDiscoveryService({ api, port }) {
-  const app = require('../lib/discovery')(PROJECT_ROOT, api) // reduce flags to only needed values
+  const app = require('../lib/discovery')(PROJECT_ROOT, api)
   return startExpressApp(app, port)
 }
 
@@ -142,6 +142,21 @@ function getStorage(runtimeApi) {
   return Storage.create(options)
 }
 
+async function untilChainIsSynched(api) {
+  while (true) {
+    const health = await api.api.rpc.system.health()
+
+    if (health.isSyncing.isTrue) {
+      debug('Waiting for chain to be synced...')
+      await new Promise((resolve) => {
+        setTimeout(resolve, 1 * 30 * 1000)
+      })
+    } else {
+      return
+    }
+  }
+}
+
 async function initApiProduction({ wsProvider, providerId, keyFile, passphrase }) {
   // Load key information
   const { RuntimeApi } = require('@joystream/storage-runtime-api')
@@ -165,6 +180,8 @@ async function initApiProduction({ wsProvider, providerId, keyFile, passphrase }
     throw new Error('Failed to unlock storage provider account')
   }
 
+  await untilChainIsSynched(api)
+
   if (!(await api.workers.isRoleAccountOfStorageProvider(api.storageProviderId, api.identities.key.address))) {
     throw new Error('storage provider role account and storageProviderId are not associated with a worker')
   }
@@ -205,6 +222,8 @@ function getServiceInformation(publicUrl) {
   }
 }
 
+// TODO: instead of recursion use while/async-await and use promise/setTimout based sleep
+// or cleaner code with generators?
 async function announcePublicUrl(api, publicUrl) {
   // re-announce in future
   const reannounce = function (timeoutMs) {
@@ -244,14 +263,14 @@ if (!command) {
   command = 'server'
 }
 
-async function startColossus({ api, publicUrl, port, flags }) {
+async function startColossus({ api, publicUrl, port }) {
   // TODO: check valid url, and valid port number
   const store = getStorage(api)
   banner()
   const { startSyncing } = require('../lib/sync')
   startSyncing(api, { syncPeriod: SYNC_PERIOD_MS }, store)
   announcePublicUrl(api, publicUrl)
-  return startAllServices({ store, api, port, flags }) // dont pass all flags only required values
+  return startAllServices({ store, api, port })
 }
 
 const commands = {

+ 1 - 1
storage-node/packages/colossus/lib/sync.js

@@ -66,7 +66,7 @@ async function syncCallback(api, storage) {
       // create relationship
       debug(`Creating new storage relationship for ${contentId.encode()}`)
       try {
-        relationshipId = await api.assets.createAndReturnStorageRelationship(roleAddress, providerId, contentId)
+        relationshipId = await api.assets.createStorageRelationship(roleAddress, providerId, contentId)
         await api.assets.toggleStorageRelationshipReady(roleAddress, providerId, relationshipId, true)
       } catch (err) {
         debug(`Error creating new storage relationship ${contentId.encode()}: ${err.stack}`)

+ 1 - 1
storage-node/packages/colossus/package.json

@@ -29,7 +29,7 @@
     "linux"
   ],
   "engines": {
-    "node": ">=10.15.3"
+    "node": ">=12.18.0"
   },
   "scripts": {
     "test": "mocha 'test/**/*.js'",

+ 1 - 1
storage-node/packages/colossus/paths/asset/v0/{id}.js

@@ -157,7 +157,7 @@ module.exports = function (storage, runtime) {
 
             debug('creating storage relationship for newly uploaded content')
             // Create storage relationship and flip it to ready.
-            const dosrId = await runtime.assets.createAndReturnStorageRelationship(roleAddress, providerId, id)
+            const dosrId = await runtime.assets.createStorageRelationship(roleAddress, providerId, id)
 
             debug('toggling storage relationship for newly uploaded content')
             await runtime.assets.toggleStorageRelationshipReady(roleAddress, providerId, dosrId, true)

+ 1 - 1
storage-node/packages/discovery/package.json

@@ -29,7 +29,7 @@
     "linux"
   ],
   "engines": {
-    "node": ">=10.15.3"
+    "node": ">=12.18.0"
   },
   "main": "./index.js",
   "scripts": {

+ 10 - 25
storage-node/packages/runtime-api/assets.js

@@ -95,17 +95,6 @@ class AssetsApi {
     return this.base.signAndSend(providerAccountId, tx)
   }
 
-  /*
-   * Creates storage relationship for a data object and provider
-   */
-  async createStorageRelationship(providerAccountId, storageProviderId, contentId, callback) {
-    contentId = parseContentId(contentId)
-    const tx = this.base.api.tx.dataObjectStorageRegistry.addRelationship(storageProviderId, contentId)
-
-    const subscribed = [['dataObjectStorageRegistry', 'DataObjectStorageRelationshipAdded']]
-    return this.base.signAndSend(providerAccountId, tx, 3, subscribed, callback)
-  }
-
   /*
    * Gets storage relationship for contentId for the given provider
    */
@@ -126,22 +115,18 @@ class AssetsApi {
   }
 
   /*
-   * Creates storage relationship for a data object and provider and returns the relationship id
+   * Creates storage relationship for a data object and provider and
+   * returns the relationship id
    */
-  async createAndReturnStorageRelationship(providerAccountId, storageProviderId, contentId) {
+  async createStorageRelationship(providerAccountId, storageProviderId, contentId) {
     contentId = parseContentId(contentId)
-    // TODO: rewrite this method to async-await style
-    // eslint-disable-next-line  no-async-promise-executor
-    return new Promise(async (resolve, reject) => {
-      try {
-        await this.createStorageRelationship(providerAccountId, storageProviderId, contentId, (events) => {
-          events.forEach((event) => {
-            resolve(event[1].DataObjectStorageRelationshipId)
-          })
-        })
-      } catch (err) {
-        reject(err)
-      }
+    const tx = this.base.api.tx.dataObjectStorageRegistry.addRelationship(storageProviderId, contentId)
+
+    return this.base.signAndSendThenGetEventResult(providerAccountId, tx, {
+      module: 'dataObjectStorageRegistry',
+      event: 'DataObjectStorageRelationshipAdded',
+      type: 'DataObjectStorageRelationshipId',
+      index: 0,
     })
   }
 

+ 4 - 3
storage-node/packages/runtime-api/identities.js

@@ -186,9 +186,10 @@ class IdentitiesApi {
     const tx = this.base.api.tx.members.buyMembership(0, userInfo)
 
     return this.base.signAndSendThenGetEventResult(accountId, tx, {
-      eventModule: 'members',
-      eventName: 'MemberRegistered',
-      eventProperty: 'MemberId',
+      module: 'members',
+      event: 'MemberRegistered',
+      type: 'MemberId',
+      index: 0,
     })
   }
 

+ 210 - 173
storage-node/packages/runtime-api/index.js

@@ -22,7 +22,6 @@ const debug = require('debug')('joystream:runtime:base')
 
 const { registerJoystreamTypes } = require('@joystream/types')
 const { ApiPromise, WsProvider } = require('@polkadot/api')
-
 const { IdentitiesApi } = require('@joystream/storage-runtime-api/identities')
 const { BalancesApi } = require('@joystream/storage-runtime-api/balances')
 const { WorkersApi } = require('@joystream/storage-runtime-api/workers')
@@ -30,7 +29,13 @@ const { AssetsApi } = require('@joystream/storage-runtime-api/assets')
 const { DiscoveryApi } = require('@joystream/storage-runtime-api/discovery')
 const { SystemApi } = require('@joystream/storage-runtime-api/system')
 const AsyncLock = require('async-lock')
-const { newExternallyControlledPromise } = require('@joystream/storage-utils/externalPromise')
+const Promise = require('bluebird')
+
+Promise.config({
+  cancellation: true,
+})
+
+const TX_TIMEOUT = 20 * 1000
 
 /*
  * Initialize runtime (substrate) API and keyring.
@@ -84,225 +89,257 @@ class RuntimeApi {
     return this.asyncLock.acquire(`${accountId}`, func)
   }
 
-  /*
-   * Wait for an event. Filters out any events that don't match the module and
-   * event name.
-   *
-   * The result of the Promise is an array containing first the full event
-   * name, and then the event fields as an object.
-   */
-  async waitForEvent(module, name) {
-    return this.waitForEvents([[module, name]])
-  }
-
-  static matchingEvents(subscribed, events) {
-    debug(`Number of events: ${events.length} subscribed to ${subscribed}`)
-
+  static matchingEvents(subscribed = [], events = []) {
     const filtered = events.filter((record) => {
-      const { event, phase } = record
-
-      // Show what we are busy with
-      debug(`\t${event.section}:${event.method}:: (phase=${phase.toString()})`)
-      debug(`\t\t${event.meta.documentation.toString()}`)
+      const { event } = record
 
       // Skip events we're not interested in.
       const matching = subscribed.filter((value) => {
-        return event.section === value[0] && event.method === value[1]
+        if (value[0] === '*' && value[1] === '*') {
+          return true
+        } else if (value[0] === '*') {
+          return event.method === value[1]
+        } else if (value[1] === '*') {
+          return event.section === value[0]
+        } else {
+          return event.section === value[0] && event.method === value[1]
+        }
       })
       return matching.length > 0
     })
-    debug(`Filtered: ${filtered.length}`)
 
-    const mapped = filtered.map((record) => {
+    return filtered.map((record) => {
       const { event } = record
       const types = event.typeDef
+      const payload = new Map()
 
-      // Loop through each of the parameters, displaying the type and data
-      const payload = {}
-      event.data.forEach((data, index) => {
-        debug(`\t\t\t${types[index].type}: ${data.toString()}`)
-        payload[types[index].type] = data
-      })
-
+      // this check may be un-necessary but doing it just incase
+      if (event.data) {
+        event.data.forEach((data, index) => {
+          const type = types[index].type
+          payload.set(index, { type, data })
+        })
+      }
       const fullName = `${event.section}.${event.method}`
+      debug(`matched event: ${fullName} =>`, event.data && event.data.join(', '))
       return [fullName, payload]
     })
-    debug('Mapped', mapped)
+  }
 
-    return mapped
+  // Get cached nonce and use unless system nonce is greater, to avoid stale nonce if
+  // there was a long gap in time between calls to signAndSend during which an external app
+  // submitted a transaction.
+  async selectBestNonce(accountId) {
+    const cachedNonce = this.nonces[accountId]
+    // In future use this rpc method to take the pending tx pool into account when fetching the nonce
+    // const nonce = await this.api.rpc.system.accountNextIndex(accountId)
+    const systemNonce = await this.api.query.system.accountNonce(accountId)
+
+    if (cachedNonce) {
+      // we have it cached.. but lets do a look ahead to see if we need to adjust
+      if (systemNonce.gt(cachedNonce)) {
+        return systemNonce
+      } else {
+        return cachedNonce
+      }
+    } else {
+      return systemNonce
+    }
   }
 
-  /*
-   * Same as waitForEvent, but filter on multiple events. The parameter is an
-   * array of arrays containing module and name. Calling waitForEvent is
-   * identical to calling this with [[module, name]].
-   *
-   * Returns the first matched event *only*.
-   */
-  async waitForEvents(subscribed) {
-    return new Promise((resolve) => {
-      this.api.query.system.events((events) => {
-        const matches = RuntimeApi.matchingEvents(subscribed, events)
-        if (matches && matches.length) {
-          resolve(matches)
-        }
-      })
-    })
+  incrementAndSaveNonce(accountId, nonce) {
+    this.nonces[accountId] = nonce.addn(1)
   }
 
   /*
-   * Nonce-aware signAndSend(). Also allows you to use the accountId instead
-   * of the key, making calls a little simpler. Will lock to prevent concurrent
-   * calls so correct nonce is used.
+   * signAndSend() with nonce tracking, to enable concurrent sending of transacctions
+   * so that they can be included in the same block. Allows you to use the accountId instead
+   * of the key, without requiring an external Signer configured on the underlying ApiPromie
    *
    * If the subscribed events are given, and a callback as well, then the
    * callback is invoked with matching events.
    */
-  async signAndSend(accountId, tx, attempts, subscribed, callback) {
+  async signAndSend(accountId, tx, subscribed) {
+    // Accept both a string or AccountId as argument
     accountId = this.identities.keyring.encodeAddress(accountId)
 
-    // Key must be unlocked
+    // Throws if keyPair is not found
     const fromKey = this.identities.keyring.getPair(accountId)
+
+    // Key must be unlocked to use
     if (fromKey.isLocked) {
       throw new Error('Must unlock key before using it to sign!')
     }
 
-    const finalizedPromise = newExternallyControlledPromise()
+    // Functions to be called when the submitted transaction is finalized. They are initialized
+    // after the transaction is submitted to the resolve and reject function of the final promise
+    // returned by signAndSend
+    // on extrinsic success
+    let onFinalizedSuccess
+    // on extrinsic failure
+    let onFinalizedFailed
 
-    await this.executeWithAccountLock(accountId, async () => {
-      // Try to get the next nonce to use
-      let nonce = this.nonces[accountId]
+    // Function assigned when transaction is successfully submitted. Invoking it ubsubscribes from
+    // listening to tx status updates.
+    let unsubscribe
 
-      let incrementNonce = () => {
-        // only increment once
-        incrementNonce = () => {
-          /* turn it into a no-op */
-        }
-        nonce = nonce.addn(1)
-        this.nonces[accountId] = nonce
-      }
+    let lastTxUpdateResult
 
-      // If the nonce isn't available, get it from chain.
-      if (!nonce) {
-        // current nonce
-        // TODO: possible race condition here found by the linter
-        // eslint-disable-next-line require-atomic-updates
-        nonce = await this.api.query.system.accountNonce(accountId)
-        debug(`Got nonce for ${accountId} from chain: ${nonce}`)
+    const handleTxUpdates = (result) => {
+      const { events = [], status } = result
+
+      if (!result || !status) {
+        return
       }
 
-      return new Promise((resolve, reject) => {
-        debug('Signing and sending tx')
-        // send(statusUpdates) returns a function for unsubscribing from status updates
-        const unsubscribe = tx
-          .sign(fromKey, { nonce })
-          .send(({ events = [], status }) => {
-            debug(`TX status: ${status.type}`)
-
-            // Whatever events we get, process them if there's someone interested.
-            // It is critical that this event handling doesn't prevent
-            try {
-              if (subscribed && callback) {
-                const matched = RuntimeApi.matchingEvents(subscribed, events)
-                debug('Matching events:', matched)
-                if (matched.length) {
-                  callback(matched)
-                }
-              }
-            } catch (err) {
-              debug(`Error handling events ${err.stack}`)
+      lastTxUpdateResult = result
+
+      if (result.isError) {
+        unsubscribe()
+        debug('Tx Error', status.type)
+        onFinalizedFailed &&
+          onFinalizedFailed({ err: status.type, result, tx: status.isUsurped ? status.asUsurped : undefined })
+      } else if (result.isFinalized) {
+        unsubscribe()
+        const mappedEvents = RuntimeApi.matchingEvents(subscribed, events)
+        const failed = result.findRecord('system', 'ExtrinsicFailed')
+        const success = result.findRecord('system', 'ExtrinsicSuccess')
+        const sudid = result.findRecord('sudo', 'Sudid')
+        const sudoAsDone = result.findRecord('sudo', 'SudoAsDone')
+
+        if (failed) {
+          const {
+            event: { data },
+          } = failed
+          const dispatchError = data[0]
+          onFinalizedFailed({
+            err: 'ExtrinsicFailed',
+            mappedEvents,
+            result,
+            block: status.asFinalized,
+            dispatchError, // we get module number/id and index into the Error enum
+          })
+        } else if (success) {
+          // Note: For root origin calls, the dispatch error is logged to the joystream-node
+          // console, we cannot get it in the events
+          if (sudid) {
+            const dispatchSuccess = sudid.event.data[0]
+            if (dispatchSuccess.isTrue) {
+              onFinalizedSuccess({ mappedEvents, result, block: status.asFinalized })
+            } else {
+              onFinalizedFailed({ err: 'SudoFailed', mappedEvents, result, block: status.asFinalized })
             }
-
-            // We want to release lock as early as possible, sometimes Ready status
-            // doesn't occur, so we do it on Broadcast instead
-            if (status.isReady) {
-              debug('TX Ready.')
-              incrementNonce()
-              resolve(unsubscribe) // releases lock
-            } else if (status.isBroadcast) {
-              debug('TX Broadcast.')
-              incrementNonce()
-              resolve(unsubscribe) // releases lock
-            } else if (status.isFinalized) {
-              debug('TX Finalized.')
-              finalizedPromise.resolve(status)
-            } else if (status.isFuture) {
-              // comes before ready.
-              // does that mean it will remain in mempool or in api internal queue?
-              // nonce was set in the future. Treating it as an error for now.
-              debug('TX Future!')
-              // nonce is likely out of sync, delete it so we reload it from chain on next attempt
-              delete this.nonces[accountId]
-              const err = new Error('transaction nonce set in future')
-              finalizedPromise.reject(err)
-              reject(err)
+          } else if (sudoAsDone) {
+            const dispatchSuccess = sudoAsDone.event.data[0]
+            if (dispatchSuccess.isTrue) {
+              onFinalizedSuccess({ mappedEvents, result, block: status.asFinalized })
+            } else {
+              onFinalizedFailed({ err: 'SudoAsFailed', mappedEvents, result, block: status.asFinalized })
             }
+          } else {
+            onFinalizedSuccess({ mappedEvents, result, block: status.asFinalized })
+          }
+        }
+      }
+    }
 
-            /* why don't we see these status updates on local devchain (single node)
-            isUsurped
-            isBroadcast
-            isDropped
-            isInvalid
-            */
-          })
-          .catch((err) => {
-            // 1014 error: Most likely you are sending transaction with the same nonce,
-            // so it assumes you want to replace existing one, but the priority is too low to replace it (priority = fee = len(encoded_transaction) currently)
-            // Remember this can also happen if in the past we sent a tx with a future nonce, and the current nonce
-            // now matches it.
-            if (err) {
-              const errstr = err.toString()
-              // not the best way to check error code.
-              // https://github.com/polkadot-js/api/blob/master/packages/rpc-provider/src/coder/index.ts#L52
-              if (
-                errstr.indexOf('Error: 1014:') < 0 && // low priority
-                errstr.indexOf('Error: 1010:') < 0
-              ) {
-                // bad transaction
-                // Error but not nonce related. (bad arguments maybe)
-                debug('TX error', err)
-              } else {
-                // nonce is likely out of sync, delete it so we reload it from chain on next attempt
-                delete this.nonces[accountId]
-              }
-            }
+    // synchronize access to nonce
+    await this.executeWithAccountLock(accountId, async () => {
+      const nonce = await this.selectBestNonce(accountId)
 
-            finalizedPromise.reject(err)
-            // releases lock
-            reject(err)
-          })
-      })
+      try {
+        unsubscribe = await tx.sign(fromKey, { nonce }).send(handleTxUpdates)
+        debug('TransactionSubmitted')
+        // transaction submitted successfully, increment and save nonce.
+        this.incrementAndSaveNonce(accountId, nonce)
+      } catch (err) {
+        const errstr = err.toString()
+        debug('TransactionRejected:', errstr)
+        // This happens when nonce is already used in finalized transactions, ie. the selected nonce
+        // was less than current account nonce. A few scenarios where this happens (other than incorrect code)
+        // 1. When a past future tx got finalized because we submitted some transactions
+        // using up the nonces upto that point.
+        // 2. Can happen while storage-node is talkig to a joystream-node that is still not fully
+        // synced.
+        // 3. Storage role account holder sent a transaction just ahead of us via another app.
+        if (errstr.indexOf('ExtrinsicStatus:: 1010: Invalid Transaction: Stale') !== -1) {
+          // In case 1 or 3 a quick recovery could work by just incrementing, but since we
+          // cannot detect which case we are in just reset and force re-reading nonce. Even
+          // that may not be sufficient expect after a few more failures..
+          delete this.nonces[accountId]
+        }
+
+        // Technically it means a transaction in the mempool with same
+        // nonce and same fees being paid so we cannot replace it, either we didn't correctly
+        // increment the nonce or someone external to this application sent a transaction
+        // with same nonce ahead of us.
+        if (errstr.indexOf('ExtrinsicStatus:: 1014: Priority is too low') !== -1) {
+          delete this.nonces[accountId]
+        }
+
+        throw err
+      }
     })
 
-    // when does it make sense to manyally unsubscribe?
-    // at this point unsubscribe.then and unsubscribe.catch have been deleted
-    // unsubscribe() // don't unsubscribe if we want to wait for additional status
-    // updates to know when the tx has been finalized
-    return finalizedPromise.promise
+    // We cannot get tx updates for a future tx so return now to avoid blocking caller
+    if (lastTxUpdateResult.status.isFuture) {
+      debug('Warning: Submitted extrinsic with future nonce')
+      return {}
+    }
+
+    // Return a promise that will resolve when the transaction finalizes.
+    // On timeout it will be rejected. Timeout is a workaround for dealing with the
+    // fact that if rpc connection is lost to node we have no way of detecting it or recovering.
+    // Timeout can also occur if a transaction that was part of batch of transactions submitted
+    // gets usurped.
+    return new Promise((resolve, reject) => {
+      onFinalizedSuccess = resolve
+      onFinalizedFailed = reject
+    }).timeout(TX_TIMEOUT)
   }
 
   /*
    * Sign and send a transaction expect event from
    * module and return eventProperty from the event.
    */
-  async signAndSendThenGetEventResult(senderAccountId, tx, { eventModule, eventName, eventProperty }) {
-    // event from a module,
-    const subscribed = [[eventModule, eventName]]
-    // TODO: rewrite this method to async-await style
-    // eslint-disable-next-line  no-async-promise-executor
-    return new Promise(async (resolve, reject) => {
-      try {
-        await this.signAndSend(senderAccountId, tx, 1, subscribed, (events) => {
-          events.forEach((event) => {
-            // fix - we may not necessarily want the first event
-            // if there are multiple events emitted,
-            resolve(event[1][eventProperty])
-          })
-        })
-      } catch (err) {
-        reject(err)
-      }
-    })
+  async signAndSendThenGetEventResult(senderAccountId, tx, { module, event, index, type }) {
+    if (!module || !event || index === undefined || !type) {
+      throw new Error('MissingSubscribeEventDetails')
+    }
+
+    const subscribed = [[module, event]]
+
+    const { mappedEvents } = await this.signAndSend(senderAccountId, tx, subscribed)
+
+    if (!mappedEvents) {
+      // The tx was a future so it was not possible and will not be possible to get events
+      throw new Error('NoEventsWereCaptured')
+    }
+
+    if (!mappedEvents.length) {
+      // our expected event was not emitted
+      throw new Error('ExpectedEventNotFound')
+    }
+
+    // fix - we may not necessarily want the first event
+    // when there are multiple instances of the same event
+    const firstEvent = mappedEvents[0]
+
+    if (firstEvent[0] !== `${module}.${event}`) {
+      throw new Error('WrongEventCaptured')
+    }
+
+    const payload = firstEvent[1]
+    if (!payload.has(index)) {
+      throw new Error('DataIndexOutOfRange')
+    }
+
+    const value = payload.get(index)
+    if (value.type !== type) {
+      throw new Error('DataTypeNotExpectedType')
+    }
+
+    return value.data
   }
 }
 

+ 1 - 1
storage-node/packages/runtime-api/package.json

@@ -30,7 +30,7 @@
     "linux"
   ],
   "engines": {
-    "node": ">=10.15.3"
+    "node": ">=12.18.0"
   },
   "scripts": {
     "test": "mocha 'test/**/*.js' --exit",

+ 12 - 9
storage-node/packages/runtime-api/workers.js

@@ -206,9 +206,10 @@ class WorkersApi {
    */
   async devSubmitAddOpeningTx(tx, senderAccount) {
     return this.base.signAndSendThenGetEventResult(senderAccount, tx, {
-      eventModule: 'storageWorkingGroup',
-      eventName: 'OpeningAdded',
-      eventProperty: 'OpeningId',
+      module: 'storageWorkingGroup',
+      event: 'OpeningAdded',
+      type: 'OpeningId',
+      index: 0,
     })
   }
 
@@ -226,9 +227,10 @@ class WorkersApi {
     )
 
     return this.base.signAndSendThenGetEventResult(memberAccount, applyTx, {
-      eventModule: 'storageWorkingGroup',
-      eventName: 'AppliedOnOpening',
-      eventProperty: 'ApplicationId',
+      module: 'storageWorkingGroup',
+      event: 'AppliedOnOpening',
+      type: 'ApplicationId',
+      index: 1,
     })
   }
 
@@ -287,9 +289,10 @@ class WorkersApi {
    */
   async devSubmitFillOpeningTx(senderAccount, tx) {
     return this.base.signAndSendThenGetEventResult(senderAccount, tx, {
-      eventModule: 'storageWorkingGroup',
-      eventName: 'OpeningFilled',
-      eventProperty: 'ApplicationIdToWorkerIdMap',
+      module: 'storageWorkingGroup',
+      event: 'OpeningFilled',
+      type: 'ApplicationIdToWorkerIdMap',
+      index: 1,
     })
   }
 }

+ 1 - 1
storage-node/packages/storage/package.json

@@ -30,7 +30,7 @@
     "linux"
   ],
   "engines": {
-    "node": ">=10.15.3"
+    "node": ">=12.18.0"
   },
   "scripts": {
     "test": "mocha --exit 'test/**/*.js'",

+ 1 - 1
storage-node/packages/util/package.json

@@ -30,7 +30,7 @@
     "linux"
   ],
   "engines": {
-    "node": ">=10.15.3"
+    "node": ">=12.18.0"
   },
   "scripts": {
     "test": "mocha 'test/**/*.js'",

+ 2 - 1
types/package.json

@@ -26,7 +26,8 @@
     "typescript": "^3.7.2"
   },
   "engines": {
-    "node": ">=10.0"
+    "node": ">=12.18.0",
+    "yarn": "^1.22.0"
   },
   "publishConfig": {
     "access": "public",