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.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user