Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,14 +756,9 @@ export class Configuration {
passive: process.env.NODE_DEX_URL_PASSIVE,
address: process.env.DEX_WALLET_ADDRESS,
},
btcInput: {
active: process.env.NODE_BTC_INP_URL_ACTIVE,
passive: process.env.NODE_BTC_INP_URL_PASSIVE,
},
btcOutput: {
active: process.env.NODE_BTC_OUT_URL_ACTIVE,
passive: process.env.NODE_BTC_OUT_URL_PASSIVE,
address: process.env.BTC_OUT_WALLET_ADDRESS,
btc: {
active: process.env.NODE_BTC_URL_ACTIVE,
passive: process.env.NODE_BTC_URL_PASSIVE,
},
walletPassword: process.env.NODE_WALLET_PASSWORD,
utxoSpenderAddress: process.env.UTXO_SPENDER_ADDRESS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Unit Tests for BitcoinClient
*
* These tests verify the correct behavior of the BitcoinClient class,
* including send methods, transaction handling, and balance queries.
* including sendMany, transaction handling, and balance queries.
*/

import { HttpService } from 'src/shared/services/http.service';
Expand All @@ -17,9 +17,6 @@ jest.mock('src/config/config', () => {
password: 'testpass',
walletPassword: 'walletpass123',
allowUnconfirmedUtxos: true,
btcOutput: {
address: 'bc1qoutputaddress',
},
},
},
};
Expand All @@ -46,6 +43,9 @@ describe('BitcoinClient', () => {
if (parsed.method === 'walletpassphrase') {
return Promise.resolve({ result: null, error: null, id: 'test' });
}
if (parsed.method === 'getnewaddress') {
return Promise.resolve({ result: 'bc1qfreshchangeaddr', error: null, id: 'test' });
}
if (parsed.method === 'send') {
return Promise.resolve({ result: { txid: 'newtxid123', complete: true }, error: null, id: 'test' });
}
Expand Down Expand Up @@ -140,77 +140,8 @@ describe('BitcoinClient', () => {
// --- Wallet Address Tests --- //

describe('walletAddress', () => {
it('should return configured output address', () => {
expect(client.walletAddress).toBe('bc1qoutputaddress');
});
});

// --- send() Method Tests (CRITICAL) --- //

describe('send() Method (CRITICAL)', () => {
it('should send with correct outputs structure', async () => {
await client.send('bc1qrecipient', 'inputtxid', 0.5, 0, 10);

const sendCall = lastRpcCalls.find((c) => c.method === 'send');
expect(sendCall).toBeDefined();

// outputs should be array of {address: amount}
const outputs = sendCall!.params[0];
expect(Array.isArray(outputs)).toBe(true);
expect(outputs[0]).toHaveProperty('bc1qrecipient');
});

it('should calculate fee correctly (135 vBytes estimate)', async () => {
// Fee calculation: (feeRate * 135) / 10^8
// With feeRate=10 sat/vB: (10 * 135) / 100000000 = 0.0000135 BTC
const result = await client.send('bc1qrecipient', 'inputtxid', 0.5, 0, 10);

expect(result.feeAmount).toBeCloseTo(0.0000135, 8);
});

it('should subtract fee from amount', async () => {
await client.send('bc1qrecipient', 'inputtxid', 0.5, 0, 10);

const sendCall = lastRpcCalls.find((c) => c.method === 'send');
const outputs = sendCall!.params[0];

// Expected output: 0.5 - 0.0000135 = 0.4999865
const outputAmount = outputs[0]['bc1qrecipient'];
expect(outputAmount).toBeCloseTo(0.4999865, 7);
});

it('should include correct options with inputs and replaceable', async () => {
await client.send('bc1qrecipient', 'inputtxid', 0.5, 2, 10);

const sendCall = lastRpcCalls.find((c) => c.method === 'send');
const options = sendCall!.params[4];

expect(options.inputs).toEqual([{ txid: 'inputtxid', vout: 2 }]);
expect(options.replaceable).toBe(true);
});

it('should pass feeRate as 4th parameter', async () => {
await client.send('bc1qrecipient', 'inputtxid', 0.5, 0, 15);

const sendCall = lastRpcCalls.find((c) => c.method === 'send');

// params: [outputs, confTarget(null), estimateMode(null), feeRate, options]
expect(sendCall!.params[3]).toBe(15);
});

it('should return outTxId from result', async () => {
const result = await client.send('bc1qrecipient', 'inputtxid', 0.5, 0, 10);

expect(result.outTxId).toBe('newtxid123');
});

it('should handle empty result gracefully', async () => {
mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' }));
mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' }));

const result = await client.send('bc1qrecipient', 'inputtxid', 0.5, 0, 10);

expect(result.outTxId).toBe('');
it('should throw because Bitcoin uses per-transaction change addresses', () => {
expect(() => client.walletAddress).toThrow();
});
});

Expand All @@ -235,15 +166,29 @@ describe('BitcoinClient', () => {
expect(outputs[1]).toHaveProperty('bc1qaddr2', 0.2);
});

it('should include change_address in options', async () => {
it('should generate a fresh change address via getnewaddress', async () => {
const payload = [{ addressTo: 'bc1qaddr1', amount: 0.1 }];

await client.sendMany(payload, 10);

const newAddrCall = lastRpcCalls.find((c) => c.method === 'getnewaddress');
expect(newAddrCall).toBeDefined();
expect(newAddrCall!.params).toEqual(['change', 'bech32']);

const sendCall = lastRpcCalls.find((c) => c.method === 'send');
const options = sendCall!.params[4];
expect(options.change_address).toBe('bc1qfreshchangeaddr');
});

it('should set lock_unspents to true', async () => {
const payload = [{ addressTo: 'bc1qaddr1', amount: 0.1 }];

expect(options.change_address).toBe('bc1qoutputaddress');
await client.sendMany(payload, 10);

const sendCall = lastRpcCalls.find((c) => c.method === 'send');
const options = sendCall!.params[4];

expect(options.lock_unspents).toBe(true);
});

it('should set replaceable to true', async () => {
Expand Down Expand Up @@ -392,89 +337,19 @@ describe('BitcoinClient', () => {
expect(result.error!.code).toBe(-25);
expect(result.error!.message).toContain('bad-txns-inputs-missingorspent');
});

it('should handle exceptions with code property', async () => {
const error = new Error('Connection failed') as Error & { code: number };
error.code = -1;

mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' }));
mockRpcPost.mockImplementationOnce(() => Promise.reject(error));

const result = await client.sendSignedTransaction('0100000001...');

expect(result.error).toBeDefined();
expect(result.error!.code).toBe(-1);
expect(result.error!.message).toContain('Connection failed');
});

it('should handle exceptions without code property', async () => {
mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' }));
mockRpcPost.mockImplementationOnce(() => Promise.reject(new Error('Unknown error')));

const result = await client.sendSignedTransaction('0100000001...');

expect(result.error!.code).toBe(-1);
expect(result.error!.message).toContain('Unknown error');
});
});

// --- getRecentHistory() Tests --- //

describe('getRecentHistory()', () => {
it('should call listtransactions with correct parameters', async () => {
await client.getRecentHistory(50);

const call = lastRpcCalls.find((c) => c.method === 'listtransactions');
expect(call!.params).toEqual(['*', 50, 0, true]);
});

it('should transform result correctly', async () => {
const result = await client.getRecentHistory();

expect(Array.isArray(result)).toBe(true);
expect(result[0].address).toBe('bc1qaddr');
expect(result[0].category).toBe('receive');
expect(result[0].amount).toBe(0.5);
expect(result[0].txid).toBe('txid1');
expect(result[0].confirmations).toBe(6);
});

it('should default txCount to 100', async () => {
await client.getRecentHistory();

const call = lastRpcCalls.find((c) => c.method === 'listtransactions');
expect(call!.params[1]).toBe(100);
});

it('should handle missing blocktime', async () => {
mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' }));
mockRpcPost.mockImplementationOnce(() =>
Promise.resolve({
result: [{ address: 'bc1q', category: 'receive', amount: 0.5, txid: 'tx1', confirmations: 0 }],
error: null,
id: 'test',
}),
);

const result = await client.getRecentHistory();

expect(result[0].blocktime).toBe(0);
});
});

// --- isTxComplete() Tests (uses getTx/gettransaction) --- //
// --- isTxComplete() Tests --- //

describe('isTxComplete()', () => {
it('should return true when TX has blockhash and confirmations > minConfirmations', async () => {
// Default mock returns confirmations: 6, so this should pass with minConfirmations: 3
const result = await client.isTxComplete('txid123', 3);

expect(result).toBe(true);
});

it('should return false when TX has no blockhash (unconfirmed)', async () => {
// gettransaction requires wallet unlock first
mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); // walletpassphrase
mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' }));
mockRpcPost.mockImplementationOnce(() =>
Promise.resolve({
result: { txid: 'txid123', confirmations: 0, time: 0, amount: 0 },
Expand All @@ -489,7 +364,7 @@ describe('BitcoinClient', () => {
});

it('should return false when TX not found', async () => {
mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); // walletpassphrase
mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' }));
mockRpcPost.mockImplementationOnce(() =>
Promise.resolve({
result: null,
Expand All @@ -502,73 +377,16 @@ describe('BitcoinClient', () => {

expect(result).toBe(false);
});

it('should use 0 as default minConfirmations', async () => {
// Default mock returns confirmations: 6, so 6 > 0 should be true
const result = await client.isTxComplete('txid123');

expect(result).toBe(true);
});

it('should return false when confirmations equals minConfirmations (boundary)', async () => {
mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); // walletpassphrase
mockRpcPost.mockImplementationOnce(() =>
Promise.resolve({
result: { txid: 'txid123', blockhash: '000...', confirmations: 5, time: 0, amount: 0 },
error: null,
id: 'test',
}),
);

// confirmations (5) is NOT > minConfirmations (5), so should be false
const result = await client.isTxComplete('txid123', 5);

expect(result).toBe(false);
});

it('should handle undefined confirmations as 0', async () => {
mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); // walletpassphrase
mockRpcPost.mockImplementationOnce(() =>
Promise.resolve({
result: { txid: 'txid123', blockhash: '000...', time: 0, amount: 0 },
error: null,
id: 'test',
}),
);

// undefined confirmations should be treated as 0, which is NOT > 0
const result = await client.isTxComplete('txid123');

expect(result).toBe(false);
});
});

// --- getNativeCoinBalance() Tests --- //

describe('getNativeCoinBalance()', () => {
it('should return wallet balance (confirmed + unconfirmed)', async () => {
// Mock returns trusted: 5.0, untrusted_pending: 0, immature: 0
// getBalance() should return 5.0 + 0 = 5.0
const result = await client.getNativeCoinBalance();

expect(result).toBe(5.0);
});

it('should include unconfirmed balance', async () => {
mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' }));
mockRpcPost.mockImplementationOnce(() =>
Promise.resolve({
result: { mine: { trusted: 3.0, untrusted_pending: 1.5, immature: 0.5 } },
error: null,
id: 'test',
}),
);

const result = await client.getNativeCoinBalance();

// Should return 3.0 + 1.5 = 4.5 (excluding immature)
expect(result).toBe(4.5);
});
});

// --- getNativeCoinBalanceForAddress() Tests --- //
Expand All @@ -585,21 +403,6 @@ describe('BitcoinClient', () => {

expect(result).toBe(0);
});

it('should search through all groupings', async () => {
const result = await client.getNativeCoinBalanceForAddress('bc1qaddr3');

expect(result).toBe(2.0);
});

it('should handle empty groupings', async () => {
mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' }));
mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: [], error: null, id: 'test' }));

const result = await client.getNativeCoinBalanceForAddress('bc1qaddr1');

expect(result).toBe(0);
});
});

// --- Unimplemented Methods Tests --- //
Expand Down
Loading
Loading