diff --git a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts index 537a354..29b6fdf 100644 --- a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts +++ b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts @@ -34,13 +34,17 @@ describe('ElectrumWalletRpcClient', () => { tx_hash: 'abc123', height: 800_000 }, - '50000', + { + outputs: [{ address: 'bc1qtest', value_sats: 50_000 }] + }, + 'bc1qtest', 800_002 ) ).toEqual({ txHash: 'abc123', amountAtomic: '50000', - confirmations: 3 + confirmations: 3, + inputOutpoints: [] }); }); @@ -51,13 +55,17 @@ describe('ElectrumWalletRpcClient', () => { tx_hash: 'abc123', height: 0 }, - '50000', + { + outputs: [{ address: 'bc1qtest', value_sats: 50_000 }] + }, + 'bc1qtest', 800_002 ) ).toEqual({ txHash: 'abc123', amountAtomic: '50000', - confirmations: 0 + confirmations: 0, + inputOutpoints: [] }); }); @@ -68,11 +76,61 @@ describe('ElectrumWalletRpcClient', () => { tx_hash: 'abc123', height: 800_000 }, - '0', + { + outputs: [{ address: 'bc1qother', value_sats: 10_000 }] + }, + 'bc1qtest', 800_002 ) ).toBeNull(); }); + + it('maps input outpoints from transaction inputs', () => { + expect( + clientTest.mapIncomingTransfer( + { + tx_hash: 'abc123', + height: 800_000 + }, + { + inputs: [ + { prevout_hash: 'abc123', prevout_n: 0 }, + { prevout_hash: 'def456', prevout_n: 2 } + ], + outputs: [{ address: 'bc1qtest', value_sats: 50_000 }] + }, + 'bc1qtest', + 800_002 + ) + ).toEqual({ + txHash: 'abc123', + amountAtomic: '50000', + confirmations: 3, + inputOutpoints: ['abc123:0', 'def456:2'] + }); + }); + + it('skips coinbase-like inputs without prevout data', () => { + expect( + clientTest.mapIncomingTransfer( + { + tx_hash: 'abc123', + height: 800_000 + }, + { + inputs: [{}, { prevout_hash: '', prevout_n: 0 }], + outputs: [{ address: 'bc1qtest', value_sats: 50_000 }] + }, + 'bc1qtest', + 800_002 + ) + ).toEqual({ + txHash: 'abc123', + amountAtomic: '50000', + confirmations: 3, + inputOutpoints: [] + }); + }); }); describe('sumOutputValueAtomic', () => { @@ -125,6 +183,7 @@ describe('ElectrumWalletRpcClient', () => { jsonrpc: '2.0', id: 'nullcart', result: { + inputs: [{ prevout_hash: 'input123', prevout_n: 0 }], outputs: [ { address: 'bc1qother', value_sats: 10_000 }, { address: 'bc1qtest', value_sats: 50_000 } @@ -137,7 +196,8 @@ describe('ElectrumWalletRpcClient', () => { { txHash: 'abc123', amountAtomic: '50000', - confirmations: 3 + confirmations: 3, + inputOutpoints: ['input123:0'] } ]); @@ -164,6 +224,105 @@ describe('ElectrumWalletRpcClient', () => { expect.any(Object) ); }); + + it('drops superseded unconfirmed transfers that share inputs with a confirmed replacement', async () => { + mockedAxios.post + .mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: 'nullcart', + result: [ + { tx_hash: 'original', height: 0 }, + { tx_hash: 'replacement', height: 800_000 } + ] + } + }) + .mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: 'nullcart', + result: '01000000' + } + }) + .mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: 'nullcart', + result: { + inputs: [{ prevout_hash: 'shared-input', prevout_n: 0 }], + outputs: [{ address: 'bc1qtest', value_sats: 50_000 }] + } + } + }) + .mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: 'nullcart', + result: '02000000' + } + }) + .mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: 'nullcart', + result: { + inputs: [{ prevout_hash: 'shared-input', prevout_n: 0 }], + outputs: [{ address: 'bc1qtest', value_sats: 50_000 }] + } + } + }); + + await expect(client.getIncomingTransfers('bc1qtest', 800_002)).resolves.toEqual([ + { + txHash: 'replacement', + amountAtomic: '50000', + confirmations: 3, + inputOutpoints: ['shared-input:0'] + } + ]); + }); + }); + + describe('filterSupersededBitcoinTransfers', () => { + it('keeps unrelated transfers unchanged', () => { + const transfers = [ + { txHash: 'a', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] }, + { txHash: 'b', amountAtomic: '1', confirmations: 3, inputOutpoints: ['in2:1'] } + ]; + + expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual(transfers); + }); + + it('drops an unconfirmed transfer superseded by a confirmed replacement', () => { + const transfers = [ + { txHash: 'original', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] }, + { txHash: 'replacement', amountAtomic: '1', confirmations: 2, inputOutpoints: ['in1:0'] } + ]; + + expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual([ + { txHash: 'replacement', amountAtomic: '1', confirmations: 2, inputOutpoints: ['in1:0'] } + ]); + }); + + it('keeps the later unconfirmed transfer when both conflict before confirmation', () => { + const transfers = [ + { txHash: 'original', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] }, + { txHash: 'replacement', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] } + ]; + + expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual([ + { txHash: 'replacement', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] } + ]); + }); + + it('keeps transfers without input outpoints', () => { + const transfers = [ + { txHash: 'coinbase', amountAtomic: '1', confirmations: 0, inputOutpoints: [] }, + { txHash: 'payment', amountAtomic: '1', confirmations: 1, inputOutpoints: ['in1:0'] } + ]; + + expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual(transfers); + }); }); describe('createAddress', () => { diff --git a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts index 40ab1f0..aa524f7 100644 --- a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts +++ b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts @@ -200,21 +200,26 @@ export class ElectrumWalletRpcClient { const serializedTransaction = await this.call('gettransaction', { txid: txHash }); const transaction = await this.deserializeTransaction(serializedTransaction); - const amountAtomic = this.sumOutputValueAtomic(transaction, address); - return this.mapIncomingTransfer(entry, amountAtomic, blockHeight); + return this.mapIncomingTransfer(entry, transaction, address, blockHeight); }) ); - return transfers.filter((transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null); + const resolvedTransfers = transfers.filter( + (transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null + ); + + return this.filterSupersededBitcoinTransfers(resolvedTransfers); } private mapIncomingTransfer( entry: ElectrumWalletAddressHistoryEntry, - amountAtomic: string, + transaction: ElectrumWalletDeserializedTransaction, + address: string, blockHeight: number | null ): ElectrumWalletIncomingTransfer | null { const txHash = entry.tx_hash; + const amountAtomic = this.sumOutputValueAtomic(transaction, address); if (!txHash || amountAtomic === '0') { return null; @@ -226,7 +231,60 @@ export class ElectrumWalletRpcClient { return { txHash, amountAtomic, - confirmations + confirmations, + inputOutpoints: this.extractInputOutpoints(transaction) }; } + + private filterSupersededBitcoinTransfers( + transfers: ElectrumWalletIncomingTransfer[] + ): ElectrumWalletIncomingTransfer[] { + return transfers.filter((transfer, index) => { + const isSuperseded = transfers.some((other, otherIndex) => { + if (otherIndex === index) { + return false; + } + + if (!this.sharesInputOutpoint(transfer.inputOutpoints, other.inputOutpoints)) { + return false; + } + + if (other.confirmations > transfer.confirmations) { + return true; + } + + return other.confirmations === transfer.confirmations && otherIndex > index; + }); + + return !isSuperseded; + }); + } + + private extractInputOutpoints(transaction: ElectrumWalletDeserializedTransaction): string[] { + if (!Array.isArray(transaction.inputs)) { + return []; + } + + return transaction.inputs.flatMap(input => { + if (typeof input.prevout_hash !== 'string' || input.prevout_hash.length === 0) { + return []; + } + + if (typeof input.prevout_n !== 'number') { + return []; + } + + return [`${input.prevout_hash}:${input.prevout_n}`]; + }); + } + + private sharesInputOutpoint(left: readonly string[], right: readonly string[]): boolean { + if (left.length === 0 || right.length === 0) { + return false; + } + + const rightOutpoints = new Set(right); + + return left.some(outpoint => rightOutpoints.has(outpoint)); + } } diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedInput.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedInput.ts new file mode 100644 index 0000000..e4332b5 --- /dev/null +++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedInput.ts @@ -0,0 +1,4 @@ +export type ElectrumWalletDeserializedInput = { + prevout_hash?: string; + prevout_n?: number; +}; diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedOutput.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedOutput.ts new file mode 100644 index 0000000..821fc5a --- /dev/null +++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedOutput.ts @@ -0,0 +1,4 @@ +export type ElectrumWalletDeserializedOutput = { + address?: string; + value_sats: number; +}; diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedTransaction.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedTransaction.ts index bb31f5b..43f3d6a 100644 --- a/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedTransaction.ts +++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedTransaction.ts @@ -1,8 +1,7 @@ -export type ElectrumWalletDeserializedOutput = { - address?: string; - value_sats: number; -}; +import type { ElectrumWalletDeserializedInput } from './ElectrumWalletDeserializedInput'; +import type { ElectrumWalletDeserializedOutput } from './ElectrumWalletDeserializedOutput'; export type ElectrumWalletDeserializedTransaction = { + inputs?: ElectrumWalletDeserializedInput[]; outputs: ElectrumWalletDeserializedOutput[]; }; diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletIncomingTransfer.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletIncomingTransfer.ts index 57de350..f8f808e 100644 --- a/backend/src/modules/bitcoinWallet/types/ElectrumWalletIncomingTransfer.ts +++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletIncomingTransfer.ts @@ -2,4 +2,5 @@ export type ElectrumWalletIncomingTransfer = { txHash: string; amountAtomic: string; confirmations: number; + inputOutpoints: string[]; }; diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts index d7eb43c..4649b9b 100644 --- a/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts +++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts @@ -5,8 +5,12 @@ import type { ElectrumWalletIncomingTransfer } from './ElectrumWalletIncomingTra export type ElectrumWalletRpcClientTest = { mapIncomingTransfer: ( entry: ElectrumWalletAddressHistoryEntry, - amountAtomic: string, + transaction: ElectrumWalletDeserializedTransaction, + address: string, blockHeight: number | null ) => ElectrumWalletIncomingTransfer | null; + filterSupersededBitcoinTransfers: ( + transfers: ElectrumWalletIncomingTransfer[] + ) => ElectrumWalletIncomingTransfer[]; sumOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string; };