From dcd499664a55a709e992804f20bfb500b0484d03 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 8 Sep 2026 18:30:06 +0200 Subject: [PATCH 1/2] Filter superseded Bitcoin transfers in Electrum wallet client. Detect RBF conflicts via shared input outpoints so invoice polling no longer ingests both the original and replacement mempool transactions. --- .../services/ElectrumWalletRpcClient.spec.ts | 171 +++++++++++++++++- .../services/ElectrumWalletRpcClient.ts | 68 ++++++- .../types/ElectrumWalletDeserializedInput.ts | 4 + .../types/ElectrumWalletDeserializedOutput.ts | 4 + .../ElectrumWalletDeserializedTransaction.ts | 7 +- .../types/ElectrumWalletIncomingTransfer.ts | 1 + .../types/ElectrumWalletRpcClientTest.ts | 6 +- 7 files changed, 245 insertions(+), 16 deletions(-) create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedInput.ts create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedOutput.ts 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; }; From 4458edb0d1b24d4d571f856a43e0f92f038b8726 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 8 Sep 2026 18:30:16 +0200 Subject: [PATCH 2/2] Prune stale unconfirmed invoice payments during wallet polling. Refactor processInvoice into upsert and prune helpers so absent 0-conf payments are removed when the wallet no longer reports them. --- .../services/InvoicePaymentService.spec.ts | 55 +++++++++++- .../payment/services/InvoicePaymentService.ts | 86 ++++++++++++------- 2 files changed, 109 insertions(+), 32 deletions(-) diff --git a/backend/src/modules/payment/services/InvoicePaymentService.spec.ts b/backend/src/modules/payment/services/InvoicePaymentService.spec.ts index fb31435..c2d1bb9 100644 --- a/backend/src/modules/payment/services/InvoicePaymentService.spec.ts +++ b/backend/src/modules/payment/services/InvoicePaymentService.spec.ts @@ -87,6 +87,7 @@ describe('InvoicePaymentService', () => { }; let paymentRepo: { update: jest.Mock; + delete: jest.Mock; createQueryBuilder: jest.Mock; }; let insertQueryBuilder: { @@ -133,6 +134,7 @@ describe('InvoicePaymentService', () => { paymentRepo = { update: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder) }; @@ -384,13 +386,35 @@ describe('InvoicePaymentService', () => { expect(paymentRepo.update).not.toHaveBeenCalled(); }); - it('does nothing when there are no transfers to process', async () => { + it('does not mutate payments when there are no transfers and no existing payments', async () => { transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice()); await service.processInvoice('invoice-1', []); expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); expect(paymentRepo.update).not.toHaveBeenCalled(); + expect(paymentRepo.delete).not.toHaveBeenCalled(); + }); + + it('removes unconfirmed payments that are no longer reported when transfers are empty', async () => { + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( + buildBtcInvoice({ + payments: [ + { + id: 'payment-ghost', + txHash: 'ghost', + amountAtomic: '50000', + confirmations: 0 + } as InvoicePayment + ] + }) + ); + + await service.processInvoice('invoice-btc-1', []); + + expect(paymentRepo.delete).toHaveBeenCalledWith('payment-ghost'); + expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); + expect(paymentRepo.update).not.toHaveBeenCalled(); }); it('skips transfers below the configured minimum', async () => { @@ -528,5 +552,34 @@ describe('InvoicePaymentService', () => { confirmations: 1 }); }); + + it('removes unconfirmed payments that are no longer reported', async () => { + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( + buildBtcInvoice({ + payments: [ + { + id: 'payment-original', + txHash: 'original', + amountAtomic: '50000', + confirmations: 0 + } as InvoicePayment, + { + id: 'payment-replacement', + txHash: 'replacement', + amountAtomic: '50000', + confirmations: 3 + } as InvoicePayment + ] + }) + ); + + await service.processInvoice('invoice-btc-1', [ + buildBtcTransfer({ txHash: 'replacement', amountAtomic: '50000', confirmations: 3 }) + ]); + + expect(paymentRepo.delete).toHaveBeenCalledWith('payment-original'); + expect(paymentRepo.update).not.toHaveBeenCalled(); + expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); }); }); diff --git a/backend/src/modules/payment/services/InvoicePaymentService.ts b/backend/src/modules/payment/services/InvoicePaymentService.ts index 1d11f83..f307480 100644 --- a/backend/src/modules/payment/services/InvoicePaymentService.ts +++ b/backend/src/modules/payment/services/InvoicePaymentService.ts @@ -135,8 +135,6 @@ export class InvoicePaymentService { } private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise { - const { minByMethod } = this.configService.get('invoice') as Config['invoice']; - await this.dataSource.transaction(async manager => { const invoiceRepo = manager.getRepository(Invoice); const paymentRepo = manager.getRepository(InvoicePayment); @@ -152,36 +150,62 @@ export class InvoicePaymentService { return; } - const minIncomingAtomic = minByMethod[invoice.paymentMethod]; - const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment])); + await this.upsertIncomingPayments(paymentRepo, invoice, transfers); - for (const transfer of transfers) { - const existing = knownByTxHash.get(transfer.txHash); - - if (existing) { - if (existing.confirmations !== transfer.confirmations) { - await paymentRepo.update(existing.id, { confirmations: transfer.confirmations }); - } - - continue; - } - - if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) { - continue; - } - - await paymentRepo - .createQueryBuilder() - .insert() - .values({ - invoice: { id: invoiceId }, - txHash: transfer.txHash, - amountAtomic: transfer.amountAtomic, - confirmations: transfer.confirmations - }) - .orIgnore() - .execute(); - } + await this.pruneAbsentUnconfirmedPayments(paymentRepo, invoice, transfers); }); } + + private async upsertIncomingPayments( + paymentRepo: Repository, + invoice: Invoice, + transfers: InvoiceIncomingTransfer[] + ): Promise { + const { minByMethod } = this.configService.get('invoice') as Config['invoice']; + + const minIncomingAtomic = minByMethod[invoice.paymentMethod]; + const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment])); + + for (const transfer of transfers) { + const existing = knownByTxHash.get(transfer.txHash); + + if (existing) { + if (existing.confirmations !== transfer.confirmations) { + await paymentRepo.update(existing.id, { confirmations: transfer.confirmations }); + } + + continue; + } + + if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) { + continue; + } + + await paymentRepo + .createQueryBuilder() + .insert() + .values({ + invoice: { id: invoice.id }, + txHash: transfer.txHash, + amountAtomic: transfer.amountAtomic, + confirmations: transfer.confirmations + }) + .orIgnore() + .execute(); + } + } + + private async pruneAbsentUnconfirmedPayments( + paymentRepo: Repository, + invoice: Invoice, + transfers: InvoiceIncomingTransfer[] + ): Promise { + const activeTxHashes = new Set(transfers.map(transfer => transfer.txHash)); + + for (const payment of invoice.payments ?? []) { + if (payment.confirmations === 0 && !activeTxHashes.has(payment.txHash)) { + await paymentRepo.delete(payment.id); + } + } + } }