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