Detect RBF conflicts via shared input outpoints so invoice polling no longer ingests both the original and replacement mempool transactions.
448 lines
16 KiB
TypeScript
448 lines
16 KiB
TypeScript
import { ConfigService } from '@nestjs/config';
|
|
import axios from 'axios';
|
|
import type { ElectrumWalletRpcClientTest } from '../types/ElectrumWalletRpcClientTest';
|
|
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
|
|
|
jest.mock('axios');
|
|
|
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
|
|
|
describe('ElectrumWalletRpcClient', () => {
|
|
let client: ElectrumWalletRpcClient;
|
|
let clientTest: ElectrumWalletRpcClientTest;
|
|
|
|
beforeEach(() => {
|
|
client = new ElectrumWalletRpcClient({
|
|
get: jest.fn().mockReturnValue({
|
|
rpcUrl: 'http://electrum.test:7777',
|
|
username: 'electrum',
|
|
password: 'secret',
|
|
walletPassword: 'wallet-secret',
|
|
rpcTimeoutMs: 5000
|
|
})
|
|
} as unknown as ConfigService);
|
|
|
|
clientTest = client as unknown as ElectrumWalletRpcClientTest;
|
|
mockedAxios.post.mockReset();
|
|
});
|
|
|
|
describe('mapIncomingTransfer', () => {
|
|
it('maps confirmed transfers with confirmations derived from block height', () => {
|
|
expect(
|
|
clientTest.mapIncomingTransfer(
|
|
{
|
|
tx_hash: 'abc123',
|
|
height: 800_000
|
|
},
|
|
{
|
|
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
|
|
},
|
|
'bc1qtest',
|
|
800_002
|
|
)
|
|
).toEqual({
|
|
txHash: 'abc123',
|
|
amountAtomic: '50000',
|
|
confirmations: 3,
|
|
inputOutpoints: []
|
|
});
|
|
});
|
|
|
|
it('returns zero confirmations for unconfirmed transfers', () => {
|
|
expect(
|
|
clientTest.mapIncomingTransfer(
|
|
{
|
|
tx_hash: 'abc123',
|
|
height: 0
|
|
},
|
|
{
|
|
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
|
|
},
|
|
'bc1qtest',
|
|
800_002
|
|
)
|
|
).toEqual({
|
|
txHash: 'abc123',
|
|
amountAtomic: '50000',
|
|
confirmations: 0,
|
|
inputOutpoints: []
|
|
});
|
|
});
|
|
|
|
it('returns null for non-positive amounts', () => {
|
|
expect(
|
|
clientTest.mapIncomingTransfer(
|
|
{
|
|
tx_hash: 'abc123',
|
|
height: 800_000
|
|
},
|
|
{
|
|
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', () => {
|
|
it('sums outputs paying to the target address', () => {
|
|
expect(
|
|
clientTest.sumOutputValueAtomic(
|
|
{
|
|
outputs: [
|
|
{ address: 'bc1qother', value_sats: 10_000 },
|
|
{ address: 'bc1qtest', value_sats: 50_000 },
|
|
{ address: 'bc1qtest', value_sats: 25_000 }
|
|
]
|
|
},
|
|
'bc1qtest'
|
|
)
|
|
).toBe('75000');
|
|
});
|
|
|
|
it('returns zero when no outputs match the address', () => {
|
|
expect(
|
|
clientTest.sumOutputValueAtomic(
|
|
{
|
|
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
|
|
},
|
|
'bc1qtest'
|
|
)
|
|
).toBe('0');
|
|
});
|
|
});
|
|
|
|
describe('getIncomingTransfers', () => {
|
|
it('resolves incoming amounts from transaction outputs', async () => {
|
|
mockedAxios.post
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
jsonrpc: '2.0',
|
|
id: 'nullcart',
|
|
result: [{ tx_hash: 'abc123', height: 800_000 }]
|
|
}
|
|
})
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
jsonrpc: '2.0',
|
|
id: 'nullcart',
|
|
result: '01000000'
|
|
}
|
|
})
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
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 }
|
|
]
|
|
}
|
|
}
|
|
});
|
|
|
|
await expect(client.getIncomingTransfers('bc1qtest', 800_002)).resolves.toEqual([
|
|
{
|
|
txHash: 'abc123',
|
|
amountAtomic: '50000',
|
|
confirmations: 3,
|
|
inputOutpoints: ['input123:0']
|
|
}
|
|
]);
|
|
|
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
|
2,
|
|
'http://electrum.test:7777',
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 'nullcart',
|
|
method: 'gettransaction',
|
|
params: { txid: 'abc123' }
|
|
},
|
|
expect.any(Object)
|
|
);
|
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
|
3,
|
|
'http://electrum.test:7777',
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 'nullcart',
|
|
method: 'deserialize',
|
|
params: { tx: '01000000' }
|
|
},
|
|
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', () => {
|
|
it('creates an address and sets a label when provided', async () => {
|
|
mockedAxios.post
|
|
.mockResolvedValueOnce({ data: { jsonrpc: '2.0', id: 'nullcart', result: 'bc1qtest' } })
|
|
.mockResolvedValueOnce({ data: { jsonrpc: '2.0', id: 'nullcart', result: true } });
|
|
|
|
await expect(client.createAddress('checkout - order-1')).resolves.toBe('bc1qtest');
|
|
|
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
|
2,
|
|
'http://electrum.test:7777',
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 'nullcart',
|
|
method: 'setlabel',
|
|
params: { key: 'bc1qtest', label: 'checkout - order-1' }
|
|
},
|
|
expect.objectContaining({
|
|
auth: { username: 'electrum', password: 'secret' }
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getBalance', () => {
|
|
it('returns confirmed and total balances in atomic units', async () => {
|
|
mockedAxios.post.mockResolvedValueOnce({
|
|
data: {
|
|
jsonrpc: '2.0',
|
|
id: 'nullcart',
|
|
result: {
|
|
confirmed: '0.00025',
|
|
unconfirmed: '0.0001'
|
|
}
|
|
}
|
|
});
|
|
|
|
await expect(client.getBalance()).resolves.toEqual({
|
|
balanceAtomic: '35000',
|
|
confirmedBalanceAtomic: '25000'
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('sweepAll', () => {
|
|
it('creates, signs, and broadcasts a max-payment transaction', async () => {
|
|
mockedAxios.post
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
jsonrpc: '2.0',
|
|
id: 'nullcart',
|
|
result: 'signed-tx'
|
|
}
|
|
})
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
jsonrpc: '2.0',
|
|
id: 'nullcart',
|
|
result: {
|
|
outputs: [{ address: 'bc1qtest', value_sats: 140_000 }]
|
|
}
|
|
}
|
|
})
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
jsonrpc: '2.0',
|
|
id: 'nullcart',
|
|
result: 'tx-hash-1'
|
|
}
|
|
});
|
|
|
|
await expect(client.sweepAll('bc1qtest', 10)).resolves.toEqual({
|
|
txHash: 'tx-hash-1',
|
|
amountAtomic: '140000'
|
|
});
|
|
|
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
|
1,
|
|
'http://electrum.test:7777',
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 'nullcart',
|
|
method: 'payto',
|
|
params: {
|
|
destination: 'bc1qtest',
|
|
amount: '!',
|
|
feerate: '10',
|
|
password: 'wallet-secret'
|
|
}
|
|
},
|
|
expect.objectContaining({
|
|
timeout: 120_000
|
|
})
|
|
);
|
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
|
2,
|
|
'http://electrum.test:7777',
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 'nullcart',
|
|
method: 'deserialize',
|
|
params: { tx: 'signed-tx' }
|
|
},
|
|
expect.any(Object)
|
|
);
|
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
|
3,
|
|
'http://electrum.test:7777',
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 'nullcart',
|
|
method: 'broadcast',
|
|
params: { tx: 'signed-tx' }
|
|
},
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
});
|
|
|
|
});
|