Skip to content

Commit 9a95647

Browse files
feat: add bitcoin package unit tests (#1848)
* feat: add bitcoin package unit tests * fixup! feat: add bitcoin package unit tests * fixup! feat: add bitcoin package unit tests
1 parent ad0e58b commit 9a95647

File tree

5 files changed

+249
-2
lines changed

5 files changed

+249
-2
lines changed

packages/bitcoin/src/wallet/lib/common/transaction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Psbt } from 'bitcoinjs-lib';
2-
import { DerivedAddress } from '@wallet';
2+
import { DerivedAddress } from './address';
33

44
export type UnsignedTransaction = {
55
context: Psbt;

packages/bitcoin/src/wallet/lib/providers/BlockchainInputResolver.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BlockchainDataProvider, UTxO } from './BitcoinDataProvider';
2-
import { InputResolver } from '@src/wallet';
2+
import { InputResolver } from './InputResolver';
33

44
/**
55
* Represents an implementation of the `InputResolver` interface that fetches detailed information
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/* eslint-disable unicorn/no-useless-undefined, no-magic-numbers, no-loop-func, @typescript-eslint/no-non-null-assertion, unicorn/consistent-function-scoping, @typescript-eslint/no-explicit-any, @typescript-eslint/no-var-requires, camelcase */
2+
import { BlockchainInputResolver } from '../src/wallet/lib/providers/BlockchainInputResolver';
3+
import { BlockchainDataProvider } from '../src/wallet/lib/providers/BitcoinDataProvider';
4+
5+
describe('BlockchainInputResolver', () => {
6+
const mockTransaction = {
7+
outputs: [
8+
{ address: 'addr1', satoshis: BigInt(1000) },
9+
{ address: 'addr2', satoshis: BigInt(2000) }
10+
]
11+
};
12+
13+
let mockProvider: BlockchainDataProvider;
14+
15+
beforeEach(() => {
16+
mockProvider = {
17+
getTransaction: jest.fn()
18+
} as unknown as BlockchainDataProvider;
19+
});
20+
21+
it('resolves a valid input correctly', async () => {
22+
(mockProvider.getTransaction as jest.Mock).mockResolvedValue(mockTransaction);
23+
24+
const resolver = new BlockchainInputResolver(mockProvider);
25+
const result = await resolver.resolve('tx123', 1);
26+
27+
expect(result).toEqual({
28+
txId: 'tx123',
29+
index: 1,
30+
address: 'addr2',
31+
satoshis: BigInt(2000)
32+
});
33+
expect(mockProvider.getTransaction).toHaveBeenCalledWith('tx123');
34+
});
35+
36+
it('throws error if transaction is not found', async () => {
37+
(mockProvider.getTransaction as jest.Mock).mockResolvedValue(undefined);
38+
39+
const resolver = new BlockchainInputResolver(mockProvider);
40+
41+
await expect(resolver.resolve('missingTx', 0)).rejects.toThrow('Transaction or output index not found');
42+
});
43+
44+
it('throws error if output index is out of range', async () => {
45+
(mockProvider.getTransaction as jest.Mock).mockResolvedValue(mockTransaction);
46+
47+
const resolver = new BlockchainInputResolver(mockProvider);
48+
49+
await expect(resolver.resolve('tx123', 5)).rejects.toThrow('Transaction or output index not found');
50+
});
51+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/* eslint-disable no-magic-numbers, no-loop-func, @typescript-eslint/no-non-null-assertion, unicorn/consistent-function-scoping, @typescript-eslint/no-explicit-any, @typescript-eslint/no-var-requires, camelcase */
2+
import { GreedyInputSelector } from '../src/wallet/lib/input-selection/GreedyInputSelector';
3+
4+
describe('GreedyInputSelector', () => {
5+
const selector = new GreedyInputSelector();
6+
const feeRate = 1; // sat/byte
7+
const changeAddress = 'change-address';
8+
9+
const createUTXO = (satoshis: bigint, id = Math.random().toString()) => ({
10+
txId: id,
11+
index: 0,
12+
address: 'address1',
13+
vout: 0,
14+
satoshis
15+
});
16+
17+
it('selects sufficient UTXOs and returns change', () => {
18+
const utxos = [createUTXO(BigInt(5000)), createUTXO(BigInt(8000))];
19+
const outputs = [{ address: 'address1', value: BigInt(4000) }];
20+
const result = selector.selectInputs(changeAddress, utxos, outputs, feeRate);
21+
22+
expect(result).toBeDefined();
23+
expect(result!.selectedUTxOs.length).toBeGreaterThan(0);
24+
expect(result!.outputs.some((o) => o.address === changeAddress)).toBe(true);
25+
expect(result!.fee).toBeGreaterThan(0);
26+
});
27+
28+
it('returns undefined if inputs are insufficient', () => {
29+
const utxos = [createUTXO(BigInt(1000))];
30+
const outputs = [{ address: 'address1', value: BigInt(2000) }];
31+
const result = selector.selectInputs(changeAddress, utxos, outputs, feeRate);
32+
33+
expect(result).toBeUndefined();
34+
});
35+
36+
it('includes all outputs and correct change when no dust', () => {
37+
const utxos = [createUTXO(BigInt(10_000))];
38+
const outputs = [
39+
{ address: 'address1', value: BigInt(3000) },
40+
{ address: 'address2', value: BigInt(2000) }
41+
];
42+
const result = selector.selectInputs(changeAddress, utxos, outputs, feeRate);
43+
44+
expect(result).toBeDefined();
45+
const sumOutputs = result!.outputs.reduce((acc, o) => acc + o.value, 0);
46+
const sumInputs = result!.selectedUTxOs.reduce((acc, u) => acc + Number(u.satoshis), 0);
47+
expect(sumInputs - sumOutputs - result!.fee).toBe(0);
48+
});
49+
50+
it('adds dust change to fee instead of outputting it', () => {
51+
const utxos = [createUTXO(BigInt(6000))];
52+
const outputs = [{ address: 'address1', value: BigInt(5500) }];
53+
const result = selector.selectInputs(changeAddress, utxos, outputs, feeRate);
54+
55+
expect(result).toBeDefined();
56+
expect(result!.outputs.some((o) => o.address === changeAddress)).toBe(false);
57+
const sumOutputs = result!.outputs.reduce((acc, o) => acc + o.value, 0);
58+
const sumInputs = result!.selectedUTxOs.reduce((acc, u) => acc + Number(u.satoshis), 0);
59+
expect(sumInputs - sumOutputs - result!.fee).toBe(0);
60+
});
61+
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/* eslint-disable no-magic-numbers, no-loop-func, @typescript-eslint/no-non-null-assertion, unicorn/consistent-function-scoping, @typescript-eslint/no-explicit-any, @typescript-eslint/no-var-requires, camelcase */
2+
import { MaestroBitcoinDataProvider } from '../src/wallet/lib/providers/MaestroBitcoinDataProvider';
3+
import { TransactionStatus, FeeEstimationMode } from '../src/wallet/lib/providers/BitcoinDataProvider';
4+
import { Network } from '../src/wallet/lib/common';
5+
6+
describe('MaestroBitcoinDataProvider', () => {
7+
let provider: MaestroBitcoinDataProvider;
8+
let mockAxios: any;
9+
let mockCache: any;
10+
let mockLogger: any;
11+
12+
const mockTxDetails = {
13+
vin: [{ txid: 'abc', vout: 0, address: 'addr1', value: '0.0001' }],
14+
vout: [{ address: 'addr2', value: '0.00009' }],
15+
confirmations: 5,
16+
blockheight: 100_000,
17+
blocktime: 1_696_969_696
18+
};
19+
20+
beforeEach(() => {
21+
mockAxios = {
22+
get: jest.fn(),
23+
post: jest.fn()
24+
};
25+
26+
mockCache = {
27+
get: jest.fn(),
28+
set: jest.fn()
29+
};
30+
31+
mockLogger = {
32+
debug: jest.fn()
33+
};
34+
35+
jest.spyOn(require('axios'), 'create').mockReturnValue(mockAxios);
36+
37+
provider = new MaestroBitcoinDataProvider('dummy-token', mockCache, mockLogger, Network.Mainnet);
38+
});
39+
40+
afterEach(() => {
41+
jest.restoreAllMocks();
42+
});
43+
44+
it('fetches last known block', async () => {
45+
mockAxios.get.mockResolvedValue({ data: { last_updated: { block_height: 100, block_hash: 'abc123' } } });
46+
47+
const result = await provider.getLastKnownBlock();
48+
expect(result).toEqual({ height: 100, hash: 'abc123' });
49+
});
50+
51+
it('fetches transaction details', async () => {
52+
const txHash = 'tx123';
53+
const txDetails = {
54+
vin: [{ txid: 'prevTx', vout: 0, address: 'addr1', value: '0.0001' }],
55+
vout: [{ address: 'addr2', value: '0.00009' }],
56+
confirmations: 10,
57+
blockheight: 500,
58+
blocktime: 1_234_567
59+
};
60+
61+
mockCache.get.mockResolvedValue();
62+
mockAxios.get.mockResolvedValue({ data: { data: txDetails } });
63+
64+
const result = await provider.getTransaction(txHash);
65+
expect(result.transactionHash).toBe(txHash);
66+
expect(result.status).toBe(TransactionStatus.Confirmed);
67+
expect(result.inputs[0].address).toBe('addr1');
68+
expect(result.outputs[0].address).toBe('addr2');
69+
});
70+
71+
it('fetches transaction history with pagination', async () => {
72+
mockAxios.get
73+
.mockResolvedValueOnce({ data: { data: [{ tx_hash: 'tx1' }], next_cursor: 'next-cursor' } })
74+
.mockResolvedValueOnce({ data: { data: mockTxDetails } });
75+
76+
mockCache.get.mockResolvedValue();
77+
78+
const result = await provider.getTransactions('addr1');
79+
expect(result.transactions.length).toBe(1);
80+
expect(result.transactions[0].transactionHash).toBe('tx1');
81+
expect(result.nextCursor).toBe('next-cursor');
82+
});
83+
84+
it('fetches mempool transactions', async () => {
85+
mockAxios.get
86+
.mockResolvedValueOnce({ data: { data: [{ txid: 'tx-mempool', mempool: true }] } })
87+
.mockResolvedValueOnce({ data: { data: mockTxDetails } });
88+
89+
mockCache.get.mockResolvedValue();
90+
91+
const result = await provider.getTransactionsInMempool('addr1');
92+
expect(result.length).toBe(1);
93+
expect(result[0].transactionHash).toBe('tx-mempool');
94+
expect(result[0].status).toBe(TransactionStatus.Pending);
95+
});
96+
97+
it('fetches UTXOs', async () => {
98+
const utxoData = [{ txid: 'abc', vout: 0, satoshis: '10000', address: 'addr1' }];
99+
mockAxios.get.mockResolvedValue({ data: { data: utxoData } });
100+
101+
const result = await provider.getUTxOs('addr1');
102+
expect(result[0].txId).toBe('abc');
103+
expect(result[0].address).toBe('addr1');
104+
expect(typeof result[0].satoshis).toBe('bigint');
105+
});
106+
107+
it('submits transaction and returns hash', async () => {
108+
mockAxios.post.mockResolvedValue({ status: 201, data: 'txhash123' });
109+
110+
const result = await provider.submitTransaction('rawtx');
111+
expect(result).toBe('txhash123');
112+
});
113+
114+
it('returns status as Confirmed', async () => {
115+
mockAxios.get.mockResolvedValue({ data: { confirmations: 3 } });
116+
117+
const result = await provider.getTransactionStatus('tx1');
118+
expect(result).toBe(TransactionStatus.Confirmed);
119+
});
120+
121+
it('returns status as Dropped for 404', async () => {
122+
mockAxios.get.mockRejectedValue({ response: { status: 404 } });
123+
124+
const result = await provider.getTransactionStatus('tx404');
125+
expect(result).toBe(TransactionStatus.Dropped);
126+
});
127+
128+
it('estimates fees', async () => {
129+
mockAxios.get.mockResolvedValue({ status: 200, data: { data: { feerate: 22, blocks: 3 } } });
130+
131+
const result = await provider.estimateFee(3, FeeEstimationMode.Conservative);
132+
expect(result.feeRate).toBe(22);
133+
expect(result.blocks).toBe(3);
134+
});
135+
});

0 commit comments

Comments
 (0)