diff --git a/CHANGELOG.md b/CHANGELOG.md index f3fdc53c..58716b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 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) 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') + }) }) })