diff --git a/CHANGELOG.md b/CHANGELOG.md index d32d83d7..58716b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- fixed: Improper handling of WebSocket message processing errors, causing sync-halting. +- fixed: Add support for processing coinbase inputs on block reward transactions. + ## 3.4.3 (2024-10-15) - fixed: Re-publish to NPM with missing files included. diff --git a/package.json b/package.json index 35ff9a76..2d6d1bc9 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "bs58": "^4.0.1", "bs58grscheck": "^2.1.2", "bs58smartcheck": "^2.0.4", - "cleaners": "^0.3.12", + "cleaners": "^0.3.17", "disklet": "^0.4.5", "ecpair": "https://github.com/EdgeApp/ecpair.git#b193eb8ea2ec0c93b528f4b0223a605407ff43e4", "edge-sync-client": "^0.2.7", diff --git a/src/common/utxobased/db/util/utxo.ts b/src/common/utxobased/db/util/utxo.ts index 611deef7..4b243c69 100644 --- a/src/common/utxobased/db/util/utxo.ts +++ b/src/common/utxobased/db/util/utxo.ts @@ -12,11 +12,10 @@ export const utxoFromTransactionDataInput = async ( inputIndex: number ): Promise => { const input = transactionData.inputs[inputIndex] - const { scriptPubkey } = input - const address = await dataLayer.fetchAddress(scriptPubkey) + const address = await dataLayer.fetchAddress(input.scriptPubkey) if (address == null) - throw new Error(`Cannot find address for ${scriptPubkey}`) + throw new Error(`Cannot find address for ${input.scriptPubkey}`) if (address.path == null) throw new Error(`Address has no derivation path information`) @@ -24,9 +23,7 @@ export const utxoFromTransactionDataInput = async ( const redeemScript = address.redeemScript const purposeType = currencyFormatToPurposeType(address.path.format) - const getScripts = async ( - scriptPubkey: string - ): Promise<{ + const getScripts = async (): Promise<{ script: string scriptType: ScriptTypeEnum }> => { @@ -41,26 +38,26 @@ export const utxoFromTransactionDataInput = async ( } case BIP43PurposeTypeEnum.WrappedSegwit: return { - script: scriptPubkey, + script: input.scriptPubkey, scriptType: ScriptTypeEnum.p2wpkhp2sh } case BIP43PurposeTypeEnum.Segwit: return { - script: scriptPubkey, + script: input.scriptPubkey, scriptType: ScriptTypeEnum.p2wpkh } default: throw new Error(`Unknown purpose type ${purposeType}`) } } - const { script, scriptType } = await getScripts(scriptPubkey) + const { script, scriptType } = await getScripts() return { id: `${input.txId}_${input.outputIndex}`, txid: input.txId, vout: input.outputIndex, value: input.amount, - scriptPubkey, + scriptPubkey: input.scriptPubkey, script, redeemScript, scriptType, diff --git a/src/common/utxobased/engine/ServerStates.ts b/src/common/utxobased/engine/ServerStates.ts index e709e991..20a538b5 100644 --- a/src/common/utxobased/engine/ServerStates.ts +++ b/src/common/utxobased/engine/ServerStates.ts @@ -231,13 +231,13 @@ export function makeServerStates(config: ServerStateConfig): ServerStates { if (uri == null || !(uri in serverStatesCache)) return false const generator = pickNextTaskCB(uri) - let nextValue: unknown - while (true) { - const result: IteratorResult< - WsTask | boolean, - boolean - > = await generator.next(nextValue) + let result: IteratorResult< + WsTask | boolean, + boolean + > = await generator.next() + + while (true) { if (result?.done === true) { return result.value } @@ -250,7 +250,18 @@ export function makeServerStates(config: ServerStateConfig): ServerStates { )}` log(`${uri} nextTask: ${taskMessage}`) } - nextValue = yield task + try { + const nextValue = yield task + result = await generator.next(nextValue) + } catch (error) { + // Delegate the error handling to the task generator: + result = await generator.throw(error).catch(error => { + // If unhandled, log the error up to the core: + log.error(error) + // End the task generator routine (task threw unhandled error) + return { done: true, value: false } + }) + } } } diff --git a/src/common/utxobased/engine/UtxoEngineProcessor.ts b/src/common/utxobased/engine/UtxoEngineProcessor.ts index c8bc4363..6f2608ba 100644 --- a/src/common/utxobased/engine/UtxoEngineProcessor.ts +++ b/src/common/utxobased/engine/UtxoEngineProcessor.ts @@ -1257,43 +1257,60 @@ const processTransactionResponse = ( args: { txResponse: TransactionResponse } ): TransactionData => { const { txResponse } = args - const inputs = txResponse.vin.map(vin => { + + let outputTotalValue = '0' + const outputs: TransactionData['outputs'] = txResponse.vout.map(vout => { const scriptPubkey = - // Note: Blockbook has empirically not sent a hex value as the - // scriptPubkey for vins. If we discover this to be changed for some - // cases, we may want to use the `hex` field as an optimization. + vout.hex ?? asMaybe( raw => validScriptPubkeyFromAddress(raw), 'unknown' )({ - address: vin.addresses[0], + address: vout.addresses[0], coin: common.pluginInfo.coinInfo.name }) + outputTotalValue = add(outputTotalValue, vout.value) return { - txId: vin.txid, - outputIndex: vin.vout, - n: vin.n, + n: vout.n, scriptPubkey, - sequence: vin.sequence, - amount: vin.value + amount: vout.value } }) - const outputs = txResponse.vout.map(vout => { + + const inputs: TransactionData['inputs'] = txResponse.vin.map(vin => { + // Handle coinbase input + if (!vin.isAddress) { + return { + amount: outputTotalValue, + n: 0, + outputIndex: 4294967295, + scriptPubkey: '', // Maybe there's a bogus scriptPubkey we can use here? + sequence: vin.sequence, + txId: '0000000000000000000000000000000000000000000000000000000000000000' + } + } + const scriptPubkey = - vout.hex ?? + // Note: Blockbook has empirically not sent a hex value as the + // scriptPubkey for vins. If we discover this to be changed for some + // cases, we may want to use the `hex` field as an optimization. asMaybe( raw => validScriptPubkeyFromAddress(raw), 'unknown' )({ - address: vout.addresses[0], + address: vin.addresses[0], coin: common.pluginInfo.coinInfo.name }) return { - n: vout.n, + txId: vin.txid, + outputIndex: vin.vout, + n: vin.n, scriptPubkey, - amount: vout.value + sequence: vin.sequence, + amount: vin.value } }) + return { txid: txResponse.txid, hex: txResponse.hex, diff --git a/src/common/utxobased/network/blockbookApi.ts b/src/common/utxobased/network/blockbookApi.ts index 5bb54ede..c679ec9b 100644 --- a/src/common/utxobased/network/blockbookApi.ts +++ b/src/common/utxobased/network/blockbookApi.ts @@ -1,12 +1,14 @@ import { asArray, asBoolean, + asEither, asMaybe, asNumber, asObject, asOptional, asString, asUnknown, + asValue, Cleaner, uncleaner } from 'cleaners' @@ -57,16 +59,24 @@ export interface BlockbookTransaction { confirmations: number blockTime: number fees: string - vin: Array<{ - txid: string - sequence: number - n: number - vout: number - addresses: string[] - isAddress: boolean - value: string - hex?: string - }> + vin: Array< + | { + addresses: string[] + hex?: string + isAddress: true + n: number + sequence: number + txid: string + value: string + vout: number + } + | { + coinbase: string + isAddress: false + n: number + sequence: number + } + > vout: Array<{ n: number value: string @@ -85,22 +95,36 @@ export const asBlockbookTransaction = ( blockTime: asNumber, fees: asString, vin: asArray( - asObject({ - txid: asString, - // Empirically observed omitted sequence is possible for when sequence is zero. - // Is the case for tx `19ecc679cfc7e71ad616a22bbee96fd5abe8616e4f408f1f5daaf137400ae091`. - sequence: asOptional(asNumber, 0), - n: asNumber, - // If Blockbook doesn't provide vout, assume 0. Empirically observed - // case for tx `fefac8c22ba1178df5d7c90b78cc1c203d1a9f5f5506f7b8f6f469fa821c2674` - // which has no `vout` for input in WebSocket response payload but block - // will show the input's vout value to be `0`. - vout: asOptional(asNumber, 0), - addresses: asArray(asAddress), - isAddress: asBoolean, - value: asString, - hex: asOptional(asString) - }) + asEither( + // Address input: + asObject({ + addresses: asArray(asAddress), + // `isAddress` is a boolean flag that indicates whether the input is an address. + // And therefore has `addresses` field. If `isAddress` is false, then the input is likely a coinbase input. + isAddress: asValue(true), + // This is the index of the input. Not to be confused with the index of the previous output (vout). + n: asNumber, + // Empirically observed omitted sequence is possible for when sequence is zero. + // Is the case for tx `19ecc679cfc7e71ad616a22bbee96fd5abe8616e4f408f1f5daaf137400ae091`. + sequence: asOptional(asNumber, 0), + txid: asString, + value: asString, + // If Blockbook doesn't provide vout, assume 0. Empirically observed + // case for tx `fefac8c22ba1178df5d7c90b78cc1c203d1a9f5f5506f7b8f6f469fa821c2674` + // which has no `vout` for input in WebSocket response payload but block + // will show the input's vout value to be `0`. + vout: asOptional(asNumber, 0), + hex: asOptional(asString) + }), + // Coinbase input: + asObject({ + // Coinbase input is a string of hex data (see example c6e617656b7b6d9fdcf8800fb5370479e5aceea4b6fe2fd74bd7bb0f3f2c64db) + coinbase: asString, + isAddress: asValue(false), + n: asNumber, + sequence: asNumber + }) + ) ), vout: asArray( asObject({ diff --git a/test/common/utxobased/network/Blockbook.spec.ts b/test/common/utxobased/network/Blockbook.spec.ts index 9c799708..ace89b65 100644 --- a/test/common/utxobased/network/Blockbook.spec.ts +++ b/test/common/utxobased/network/Blockbook.spec.ts @@ -272,14 +272,16 @@ describe('Blockbook', function () { }) describe('fetchTransaction', function () { - const satoshiHash = - '3ed86f1b0a0a6fe180195bc1f93fd9d0801aea8c8ad5018de82c026dc21e2b15' it('should fetch details from a transaction hash', async function () { + const satoshiHash = + '3ed86f1b0a0a6fe180195bc1f93fd9d0801aea8c8ad5018de82c026dc21e2b15' const tx = await blockbook.fetchTransaction(satoshiHash) tx.txid.should.equal(satoshiHash) tx.fees.should.equal('226') tx.blockHeight.should.equal(651329) + tx.vin[0].isAddress.should.equal(true) + if (!tx.vin[0].isAddress) throw new Error('Type assertion failed') tx.vin[0].value.should.equal('97373') tx.vin[0].txid.should.equal( 'fac5994d454817db2daec796cfa79cce670a372e7505fdef2a259289d5df0814' @@ -289,12 +291,32 @@ describe('Blockbook', function () { tx.vin[0].addresses.should.eqls([ 'bc1qg6lwu6c8yqhhw7rrq69akknepxcft09agkkuqv' ]) - tx.vin[0].isAddress.should.equal(true) tx.vin[0].value.should.equal('97373') tx.vout[1].value.should.equal('95000') tx.should.have.property('confirmations') tx.should.have.property('blockTime') }) + + it('should fetch details from a coinbase transaction hash', async function () { + const blockRewardTxid = + 'c6e617656b7b6d9fdcf8800fb5370479e5aceea4b6fe2fd74bd7bb0f3f2c64db' + const tx = await blockbook.fetchTransaction(blockRewardTxid) + + tx.txid.should.equal(blockRewardTxid) + tx.fees.should.equal('0') + tx.blockHeight.should.equal(868078) + tx.vin[0].isAddress.should.equal(false) + if (tx.vin[0].isAddress) throw new Error('Type assertion failed') + tx.vin[0].coinbase.should.equal( + '03ee3e0d1e3c204f4345414e2e58595a203e0f456c656b74726f6e20456e657267790007155cb4e4ff33420eb387b0ccf1ee1567020000000000' + ) + expect(tx.vin[0].sequence).to.equal(4294967295) + tx.vin[0].n.should.equal(0) + + tx.vout[1].value.should.equal('135549329') + tx.should.have.property('confirmations') + tx.should.have.property('blockTime') + }) }) }) diff --git a/yarn.lock b/yarn.lock index cb71488e..cd79730e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2936,11 +2936,16 @@ clean-stack@^2.0.0: resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== -cleaners@^0.3.12, cleaners@^0.3.14, cleaners@^0.3.9: +cleaners@^0.3.14, cleaners@^0.3.9: version "0.3.16" resolved "https://registry.yarnpkg.com/cleaners/-/cleaners-0.3.16.tgz#d3a7ab936cb78b0d6ac19ba87b2d28ec41f30502" integrity sha512-Ecu8Fwv3wT7GV44K4Zas2CQJI11ZV/yPwoh8Gg9BSV6rAmhNsqFeWceA1RII1czbarFSG3XClUePrpI806OvRw== +cleaners@^0.3.17: + version "0.3.17" + resolved "https://registry.yarnpkg.com/cleaners/-/cleaners-0.3.17.tgz#dae498f3d49b7e9364050402d2f4ad09abcd31ba" + integrity sha512-X5acjsLwJK+JEK5hv0Rve7G78+E6iYh1TzJZ40z7Yjrba0WhW6spTq28WgG9w+AK+YQIOHtQTrzaiuntMBBIwQ== + cli-boxes@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f"