From 9222ab096b521c759524f35ee7544493effa0b7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 02:02:21 +0000 Subject: [PATCH 1/3] Initial plan From 610ee86930e916cc8ab5981595faa0e511c68191 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 02:06:57 +0000 Subject: [PATCH 2/3] feat: add fee validation for sweepAll transactions to prevent excessive fees Co-authored-by: Corey-Code <37006206+Corey-Code@users.noreply.github.com> --- src/lib/bitcoin/transaction.ts | 10 +++ tests/lib/bitcoin/transaction.test.ts | 119 ++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/src/lib/bitcoin/transaction.ts b/src/lib/bitcoin/transaction.ts index 5e3ebe4..7e17426 100644 --- a/src/lib/bitcoin/transaction.ts +++ b/src/lib/bitcoin/transaction.ts @@ -832,6 +832,16 @@ export async function createSendTransaction( const sweepAmount = totalInput - fee; + // Validate that fee doesn't exceed a reasonable percentage of total balance + const feePercentage = (fee / totalInput) * 100; + const MAX_FEE_PERCENTAGE = 20; // 20% threshold + if (feePercentage > MAX_FEE_PERCENTAGE) { + throw new Error( + `Fee too high: ${fee} sats (${feePercentage.toFixed(1)}%) exceeds ${MAX_FEE_PERCENTAGE}% of total balance (${totalInput} sats). ` + + `This may happen when sweeping many small UTXOs with high fee rates. Consider consolidating UTXOs at a lower fee rate first.` + ); + } + if (sweepAmount <= 0) { throw new Error(`Insufficient funds: fee (${fee} sats) exceeds balance (${totalInput} sats)`); } diff --git a/tests/lib/bitcoin/transaction.test.ts b/tests/lib/bitcoin/transaction.test.ts index 05e3340..b9ff7b7 100644 --- a/tests/lib/bitcoin/transaction.test.ts +++ b/tests/lib/bitcoin/transaction.test.ts @@ -292,5 +292,124 @@ describe('UTXO Transaction Module', () => { expect(result.fee).toBeGreaterThan(1000); expect(result.fee).toBeLessThan(3000); }); + + it('should create sweepAll transaction successfully', async () => { + const { privateKey, publicKey } = await generateTestKeys(); + + const utxos = [ + { + txid: '0'.repeat(64), + vout: 0, + value: 100000, // 100k sats + status: { confirmed: true }, + }, + ]; + + const result = await createSendTransaction( + utxos, + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + 0, // Amount doesn't matter for sweepAll + 10, // 10 sat/vB + privateKey, + publicKey, + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + BITCOIN_MAINNET, + { sweepAll: true } + ); + + expect(result.txHex).toBeDefined(); + expect(result.txid).toBeDefined(); + // Fee should be roughly 1100 sats (1 input, 1 output at 10 sat/vB) + expect(result.fee).toBeGreaterThan(800); + expect(result.fee).toBeLessThan(1500); + }); + + it('should throw error when sweepAll fee exceeds 20% of balance', async () => { + const { privateKey, publicKey } = await generateTestKeys(); + + // Create many small UTXOs to trigger high fee scenario + const utxos = Array.from({ length: 50 }, (_, i) => ({ + txid: i.toString().padStart(64, '0'), + vout: 0, + value: 1000, // 1000 sats each = 50k total + status: { confirmed: true }, + })); + + await expect( + createSendTransaction( + utxos, + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + 0, + 100, // High fee rate: 100 sat/vB + privateKey, + publicKey, + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + BITCOIN_MAINNET, + { sweepAll: true } + ) + ).rejects.toThrow(/Fee too high.*exceeds 20% of total balance/); + }); + + it('should allow sweepAll when fee is below 20% threshold', async () => { + const { privateKey, publicKey } = await generateTestKeys(); + + // Fewer UTXOs with reasonable balance + const utxos = Array.from({ length: 5 }, (_, i) => ({ + txid: i.toString().padStart(64, '0'), + vout: 0, + value: 50000, // 50k sats each = 250k total + status: { confirmed: true }, + })); + + const result = await createSendTransaction( + utxos, + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + 0, + 10, // 10 sat/vB + privateKey, + publicKey, + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + BITCOIN_MAINNET, + { sweepAll: true } + ); + + expect(result.txHex).toBeDefined(); + expect(result.txid).toBeDefined(); + // With 5 inputs and 1 output, fee should be reasonable + expect(result.fee).toBeGreaterThan(0); + expect(result.fee).toBeLessThan(50000); // Well below 20% of 250k + }); + + it('should successfully sweep when fee is reasonable and output is above dust', async () => { + const { privateKey, publicKey } = await generateTestKeys(); + + const utxos = [ + { + txid: '0'.repeat(64), + vout: 0, + value: 10000, // 10k sats + status: { confirmed: true }, + }, + ]; + + // Using lower fee rate to get under 20% and result above dust threshold + const result = await createSendTransaction( + utxos, + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + 0, + 8, // 8 sat/vB: fee = 880 sats (8.8%), leaving 9120 sats (well above dust) + privateKey, + publicKey, + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + BITCOIN_MAINNET, + { sweepAll: true } + ); + + // This should succeed since fee is under 20% and result is above dust + expect(result.txHex).toBeDefined(); + expect(result.txid).toBeDefined(); + expect(result.fee).toBeGreaterThan(0); + expect(result.fee).toBeLessThan(2000); // Fee should be reasonable + }); }); }); From ff4a834b0518c6dcfbcf85cb1928e6c15b4d217b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 02:07:57 +0000 Subject: [PATCH 3/3] refactor: extract MAX_SWEEP_FEE_PERCENTAGE as module constant and clarify test comment Co-authored-by: Corey-Code <37006206+Corey-Code@users.noreply.github.com> --- src/lib/bitcoin/transaction.ts | 8 +++++--- tests/lib/bitcoin/transaction.test.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib/bitcoin/transaction.ts b/src/lib/bitcoin/transaction.ts index 7e17426..2836a69 100644 --- a/src/lib/bitcoin/transaction.ts +++ b/src/lib/bitcoin/transaction.ts @@ -77,6 +77,9 @@ const OP_EQUALVERIFY = 0x88; const OP_CHECKSIG = 0xac; const OP_0 = 0x00; +// Fee validation thresholds +const MAX_SWEEP_FEE_PERCENTAGE = 20; // Maximum fee as percentage of total balance for sweep transactions + // ============================================================================ // Utility Functions // ============================================================================ @@ -834,10 +837,9 @@ export async function createSendTransaction( // Validate that fee doesn't exceed a reasonable percentage of total balance const feePercentage = (fee / totalInput) * 100; - const MAX_FEE_PERCENTAGE = 20; // 20% threshold - if (feePercentage > MAX_FEE_PERCENTAGE) { + if (feePercentage > MAX_SWEEP_FEE_PERCENTAGE) { throw new Error( - `Fee too high: ${fee} sats (${feePercentage.toFixed(1)}%) exceeds ${MAX_FEE_PERCENTAGE}% of total balance (${totalInput} sats). ` + + `Fee too high: ${fee} sats (${feePercentage.toFixed(1)}%) exceeds ${MAX_SWEEP_FEE_PERCENTAGE}% of total balance (${totalInput} sats). ` + `This may happen when sweeping many small UTXOs with high fee rates. Consider consolidating UTXOs at a lower fee rate first.` ); } diff --git a/tests/lib/bitcoin/transaction.test.ts b/tests/lib/bitcoin/transaction.test.ts index b9ff7b7..591aaf3 100644 --- a/tests/lib/bitcoin/transaction.test.ts +++ b/tests/lib/bitcoin/transaction.test.ts @@ -397,7 +397,7 @@ describe('UTXO Transaction Module', () => { utxos, 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', 0, - 8, // 8 sat/vB: fee = 880 sats (8.8%), leaving 9120 sats (well above dust) + 8, // 8 sat/vB: fee ≈ 880 sats (≈8.8%), leaving ≈9120 sats (well above dust) privateKey, publicKey, 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4',