/ tracker / transaction.js
transaction.js
  1  /*!
  2   * tracker/transaction.js
  3   * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved.
  4   */
  5  
  6  
  7  import util from '../lib/util.js'
  8  import Logger from '../lib/logger.js'
  9  import addrHelper from '../lib/bitcoin/addresses-helper.js'
 10  import hdaHelper from '../lib/bitcoin/hd-accounts-helper.js'
 11  import db from '../lib/db/mysql-db-wrapper.js'
 12  import network from '../lib/bitcoin/network.js'
 13  import keysFile from '../keys/index.js'
 14  import { TransactionsCache } from './transactions-cache.js'
 15  
 16  const keys = keysFile[network.key]
 17  const gapLimit = [keys.gap.external, keys.gap.internal]
 18  
 19  /**
 20   * @typedef {import('bitcoinjs-lib').Transaction} bitcoin.Transaction
 21   */
 22  
 23  /**
 24   * A class allowing to process a transaction
 25   */
 26  class Transaction {
 27  
 28      /**
 29       * Constructor
 30       * @param {bitcoin.Transaction} tx - transaction object
 31       */
 32      constructor(tx) {
 33          /**
 34           * @type {bitcoin.Transaction}
 35           */
 36          this.tx = tx
 37          /**
 38           * @type {string}
 39           */
 40          this.txid = this.tx.getId()
 41          /**
 42           * ID of transaction stored in db
 43           * @type {number | null}
 44           */
 45          this.storedTxnID = null
 46          /**
 47           * Should this transaction be broadcast out to connected clients?
 48           * @type {boolean}
 49           */
 50          this.doBroadcast = false
 51          /**
 52           * Transaction is being processed
 53           * @type {null | Promise<void>}
 54           */
 55          this.storingTransaction = null
 56      }
 57  
 58      /**
 59       * Register transaction in db if it's a transaction of interest
 60       * @returns {Promise<{ tx:object, broadcast: boolean }>} returns a composite result object
 61       */
 62      async checkTransaction() {
 63          try {
 64              // Process transaction inputs and outputs
 65              await Promise.all([this.processInputs(), this.processOutputs()])
 66  
 67              // If this point reached with no errors,
 68              // store the fact that this transaction was checked.
 69              TransactionsCache.set(this.txid, this.doBroadcast)
 70  
 71              return {
 72                  broadcast: this.doBroadcast
 73              }
 74  
 75          } catch (error) {
 76              Logger.error(error, 'Tracker : Transaction.checkTransaction()')
 77              throw error
 78          }
 79      }
 80  
 81      /**
 82       * Process transaction inputs
 83       * @returns {Promise<void>}
 84       */
 85      async processInputs() {
 86          // Array of inputs spent
 87          const spends = []
 88          // Store input indices, keyed by `txid-outindex` for easy retrieval
 89          const indexedInputs = {}
 90          // Store database ids of double spend transactions
 91          const doubleSpentTxnIDs = []
 92          // Store inputs of interest
 93          const inputs = []
 94  
 95          // Extracts inputs information
 96          let index = 0
 97  
 98          for (let input of this.tx.ins) {
 99              const spendTxid = Buffer.from(input.hash).reverse().toString('hex')
100              spends.push({ txid: spendTxid, index: input.index })
101              indexedInputs[`${spendTxid}-${input.index}`] = index
102              index++
103          }
104  
105          // Check if we find some inputs of interest
106          const results = await db.getOutputSpends(spends)
107  
108          if (results.length === 0)
109              return null
110  
111          // Flag the transaction for broadcast
112          this.doBroadcast = true
113  
114          // This transaction is spending an existing output.
115          // This is value leaving a wallet's addresses.
116          // Each result contains
117          //  {outID, addrAddress, outAmount, txnTxid, outIndex, spendingTxnID/null}
118  
119          // Store the transaction in db
120          await this._ensureTransaction()
121  
122          // Prepare the inputs
123          for (let r of results) {
124              const index = indexedInputs[`${r.txnTxid}-${r.outIndex}`]
125  
126              inputs.push({
127                  txnID: this.storedTxnID,
128                  outID: r.outID,
129                  inIndex: index,
130                  inSequence: this.tx.ins[index].sequence
131              })
132  
133              // Detect potential double spends
134              if (r.spendingTxnID != null && r.spendingTxnID !== this.storedTxnID) {
135                  Logger.info(`Tracker : DOUBLE SPEND of ${r.txnTxid}-${r.outIndex} by ${this.txid}!`)
136                  // Delete the existing transaction that has been double-spent:
137                  // since the deepest block keeps its transactions, this will
138                  // eventually work itself out, and the wallet will not show
139                  // two transactions spending the same output.
140                  doubleSpentTxnIDs.push(r.spendingTxnID)
141              }
142          }
143  
144          // Record the inputs of interest in the database
145          await db.addInputs(inputs)
146  
147          // Process the double spends
148          if (doubleSpentTxnIDs.length > 0) {
149              // Get txids to update LRU cache
150              const txs = await db.getTransactionsById(doubleSpentTxnIDs)
151  
152              for (let tx of txs)
153                  TransactionsCache.delete(tx.txnTxid)
154  
155              await db.deleteTransactionsByID(doubleSpentTxnIDs)
156          }
157      }
158  
159      /**
160       * Process transaction outputs
161       * @returns {Promise<void>}
162       */
163      async processOutputs() {
164          // Store outputs, keyed by address. Values are arrays of outputs
165          const indexedOutputs = {}
166  
167          // Extracts outputs information
168          let index = 0
169  
170          for (let output of this.tx.outs) {
171              const address = addrHelper.outputScript2Address(output.script)
172  
173              if (address) {
174                  if (!indexedOutputs[address])
175                      indexedOutputs[address] = []
176  
177                  indexedOutputs[address].push({
178                      index,
179                      value: output.value,
180                      script: output.script.toString('hex'),
181                  })
182              }
183              index++
184          }
185  
186          // Array of addresses receiving tx outputs
187          const addresses = Object.keys(indexedOutputs)
188  
189          // Store a list of known addresses that received funds
190          let fundedAddresses = []
191  
192          // Get HD Accounts that own any of the output addresses
193          const result = await db.getHDAccountsByAddresses(addresses)
194  
195          // Get outputs spending to loose addresses first
196          const aLooseAddr = this._processOutputsLooseAddresses(result.loose, indexedOutputs)
197          fundedAddresses = [...fundedAddresses, ...aLooseAddr]
198  
199          // Get outputs spending to a tracked account
200          const aHdAcctAddr = await this._processOutputsHdAccounts(result.hd, indexedOutputs)
201          fundedAddresses = [...fundedAddresses, ...aHdAcctAddr]
202  
203          if (fundedAddresses.length === 0)
204              return null
205  
206          // Flag the transaction for broadcast
207          this.doBroadcast = true
208  
209          // Add the transaction to the database
210          await this._ensureTransaction()
211  
212          // Associate transaction outputs with known addresses
213          const outputs = []
214  
215          for (let a of fundedAddresses) {
216              outputs.push({
217                  txnID: this.storedTxnID,
218                  addrID: a.addrID,
219                  outIndex: a.outIndex,
220                  outAmount: a.outAmount,
221                  outScript: a.outScript,
222              })
223          }
224  
225          await db.addOutputs(outputs)
226      }
227  
228      /**
229       * Process outputs sending to tracked loose addresses
230       * @param {object[]} addresses - array of address objects
231       * @param {object} indexedOutputs - outputs indexed by address
232       * @returns {object[]} return an array of funded addresses
233       *  {addrID: ..., outIndex: ..., outAmount: ..., outScript: ...}
234       */
235      _processOutputsLooseAddresses(addresses, indexedOutputs) {
236          // Store a list of known addresses that received funds
237          const fundedAddresses = []
238  
239          // Get outputs spending to loose addresses first
240          for (let a of addresses) {
241              if (indexedOutputs[a.addrAddress]) {
242                  for (let output of indexedOutputs[a.addrAddress]) {
243                      fundedAddresses.push({
244                          addrID: a.addrID,
245                          outIndex: output.index,
246                          outAmount: output.value,
247                          outScript: output.script,
248                      })
249                  }
250              }
251          }
252  
253          return fundedAddresses
254      }
255  
256      /**
257       * Process outputs sending to tracked hd accounts
258       * @param {object[]} hdAccounts - array of hd account objects
259       * @param {object} indexedOutputs - outputs indexed by address
260       * @returns {Promise<object[]>} return an array of funded addresses
261       *  {addrID: ..., outIndex: ..., outAmount: ..., outScript: ...}
262       */
263      async _processOutputsHdAccounts(hdAccounts, indexedOutputs) {
264          // Store a list of known addresses that received funds
265          const fundedAddresses = []
266          const xpubList = Object.keys(hdAccounts)
267  
268          if (xpubList.length > 0) {
269              await util.parallelCall(xpubList, async xpub => {
270                  const usedNewAddresses = await this._deriveNewAddresses(
271                      xpub,
272                      hdAccounts[xpub],
273                      indexedOutputs
274                  )
275  
276                  const usedNewResults = await db.getAddresses(usedNewAddresses)
277  
278                  // Append these address results to the hdAccount address list
279                  Array.prototype.push.apply(hdAccounts[xpub].addresses, usedNewResults)
280  
281                  for (let entry of hdAccounts[xpub].addresses) {
282                      if (indexedOutputs[entry.addrAddress]) {
283                          for (let output of indexedOutputs[entry.addrAddress]) {
284                              fundedAddresses.push({
285                                  addrID: entry.addrID,
286                                  outIndex: output.index,
287                                  outAmount: output.value,
288                                  outScript: output.script,
289                              })
290                          }
291                      }
292                  }
293              })
294          }
295  
296          return fundedAddresses
297      }
298  
299      /**
300       * Derive new addresses for a hd account
301       * Check if tx addresses are at or beyond the next unused
302       * index for the HD chain. Derive additional addresses
303       * to replace the gap limit and add those addresses to
304       * the database. Make sure to account for tx sending to
305       * newly-derived addresses.
306       *
307       * @param {string} xpub
308       * @param {object} hdAccount - hd account object
309       * @param {object} indexedOutputs - outputs indexed by address
310       * @returns {Promise<object[]>} returns an array of the new addresses used
311       */
312      async _deriveNewAddresses(xpub, hdAccount, indexedOutputs) {
313          const hdType = hdAccount.hdType
314  
315          let derivedIndices = [-1, -1]
316  
317          // Get maximum derived address indices for each chain
318          derivedIndices = await db.getHDAccountDerivedIndices(xpub)
319  
320          // Get the next unused chain indices for this account
321          const unusedIndices = await db.getHDAccountNextUnusedIndices(xpub)
322  
323          const newAddresses = []
324          const usedNewAddresses = {}
325  
326          // Get the maximum used index in the addresses
327          for (let chain of [0, 1]) {
328              // Get addresses for this account that are on this chain
329              const chainAddresses = hdAccount.addresses.filter(v => {
330                  return v.hdAddrChain === chain
331              })
332  
333              if (chainAddresses.length === 0)
334                  continue
335  
336              // Get the maximum used address on this chain
337              const chainMaxUsed = util.maxBy(chainAddresses, a => {
338                  return a.hdAddrIndex
339              })
340  
341              let chainMaxUsedIndex = chainMaxUsed.hdAddrIndex
342  
343              // If max used index will not advance the unused index, move on
344              if (chainMaxUsedIndex < unusedIndices[chain])
345                  continue
346  
347              // If max derived index is beyond max used index plus gap limit.
348              if (derivedIndices[chain] >= chainMaxUsedIndex + gapLimit[chain]) {
349                  // Check that we don't have a hole in the next <gapLimit> indices
350                  const nbDerivedIndicesForward = await db.getHDAccountNbDerivedIndices(
351                      xpub,
352                      chain,
353                      chainMaxUsedIndex,
354                      chainMaxUsedIndex + gapLimit[chain]
355                  )
356  
357                  if (nbDerivedIndicesForward < gapLimit[chain] + 1) {
358                      // Hole detected. Force derivation.
359                      derivedIndices[chain] = chainMaxUsedIndex
360                  } else {
361                      // Move on
362                      continue
363                  }
364              }
365  
366              let done
367  
368              do {
369                  done = true
370  
371                  // Derive additional addresses beyond the max index...
372                  // ..and including the gap limit beyond the max used
373                  const minIndex = derivedIndices[chain] + 1
374                  const maxIndex = chainMaxUsedIndex + gapLimit[chain] + 1
375                  const indices = util.range(minIndex, maxIndex)
376  
377                  const derived = await hdaHelper.deriveAddresses(xpub, chain, indices, hdType)
378  
379                  newAddresses.push(...derived)
380  
381                  Logger.info(`Tracker : Derived hdID(${hdAccount.hdID}) M/${chain}/${indices.join(',')}`)
382  
383                  // Update view of derived address indices
384                  derivedIndices[chain] = chainMaxUsedIndex + gapLimit[chain]
385  
386                  // Check derived addresses for use in this transaction
387                  for (let d of derived) {
388                      if (indexedOutputs[d.address]) {
389                          Logger.info(`Tracker : Derived address already in outputs: M/${d.chain}/${d.index}`)
390                          // This transaction spends to an address
391                          // beyond the original derived gap limit!
392                          chainMaxUsedIndex = d.index
393                          usedNewAddresses[d.address] = d
394                          done = false
395                      }
396                  }
397              } while (!done)
398  
399          }
400  
401          await db.addAddressesToHDAccount(xpub, newAddresses)
402          return Object.keys(usedNewAddresses)
403      }
404  
405  
406      /**
407       * Store the transaction in database
408       * @returns {Promise<void>}
409       */
410      _ensureTransaction() {
411          if (this.storingTransaction !== null) return this.storingTransaction
412  
413          return this.storingTransaction = (async () => {
414              this.storedTxnID = await db.ensureTransactionId(this.txid)
415  
416              await db.addTransaction({
417                  txid: this.txid,
418                  version: this.tx.version,
419                  locktime: this.tx.locktime,
420              })
421  
422              Logger.info(`Tracker :  Storing transaction ${this.txid}`)
423          })()
424      }
425  
426  }
427  
428  export default Transaction