From 023803ecbab83c5edf60849a1d3170a377e73c62 Mon Sep 17 00:00:00 2001 From: Angel Castillo Date: Wed, 2 Jul 2025 15:14:50 +0800 Subject: [PATCH 1/2] fix: bitcoin input selector now tries to rescue dust if possible --- .../features/send/components/SendStepOne.tsx | 2 +- .../input-selection/GreedyInputSelector.ts | 103 ++++++++++++++---- .../lib/wallet/MaestroFeeMarketProvider.ts | 8 +- .../lib/wallet/MempoolSpaceMarketProvider.ts | 8 +- .../src/wallet/lib/wallet/constants.ts | 4 +- .../bitcoin/test/GreedyInputSelector.test.ts | 48 +++++++- 6 files changed, 133 insertions(+), 40 deletions(-) diff --git a/apps/browser-extension-wallet/src/views/bitcoin-mode/features/send/components/SendStepOne.tsx b/apps/browser-extension-wallet/src/views/bitcoin-mode/features/send/components/SendStepOne.tsx index e1dc5d17a..2c9f9ee70 100644 --- a/apps/browser-extension-wallet/src/views/bitcoin-mode/features/send/components/SendStepOne.tsx +++ b/apps/browser-extension-wallet/src/views/bitcoin-mode/features/send/components/SendStepOne.tsx @@ -34,7 +34,7 @@ interface RecommendedFee { const fees: RecommendedFee[] = [ { key: 'fast', label: 'Fast', feeRate: 10, estimatedTime: '~10 min' }, { key: 'standard', label: 'Average', feeRate: 5, estimatedTime: '~30 min' }, - { key: 'slow', label: 'Low', feeRate: 1, estimatedTime: '~60 min' }, + { key: 'slow', label: 'Slow', feeRate: 1, estimatedTime: '~60 min' }, { key: 'custom', label: 'Custom', estimatedTime: '~?? min' } ]; diff --git a/packages/bitcoin/src/wallet/lib/input-selection/GreedyInputSelector.ts b/packages/bitcoin/src/wallet/lib/input-selection/GreedyInputSelector.ts index 2eb66cbd7..1a8a7f133 100644 --- a/packages/bitcoin/src/wallet/lib/input-selection/GreedyInputSelector.ts +++ b/packages/bitcoin/src/wallet/lib/input-selection/GreedyInputSelector.ts @@ -3,54 +3,109 @@ import { UTxO } from '../providers'; import { INPUT_SIZE, OUTPUT_SIZE, TRANSACTION_OVERHEAD, DUST_THRESHOLD } from '../common'; import { InputSelector } from './InputSelector'; +const ZERO = BigInt(0); + /** * A concrete implementation of InputSelector that uses a simple greedy algorithm. */ export class GreedyInputSelector implements InputSelector { - selectInputs( + public selectInputs( changeAddress: string, utxos: UTxO[], outputs: { address: string; value: bigint }[], feeRate: number ): { selectedUTxOs: UTxO[]; outputs: { address: string; value: number }[]; fee: number } | undefined { - const selectedUTxOs: UTxO[] = []; - let inputSum = BigInt(0); + const selected: UTxO[] = []; + const totalOutput = outputs.reduce((acc, o) => acc + o.value, ZERO); + let inputSum = ZERO; let fee = 0; - const totalOutput: bigint = outputs.reduce((acc, out) => acc + out.value, BigInt(0)); - for (const utxo of utxos) { - selectedUTxOs.push(utxo); + selected.push(utxo); inputSum += utxo.satoshis; + fee = this.computeFee(selected.length, outputs.length + 1, feeRate); + if (inputSum >= totalOutput + BigInt(fee)) break; + } + + if (inputSum < totalOutput + BigInt(fee)) return undefined; - const estimatedSize = - selectedUTxOs.length * INPUT_SIZE + (outputs.length + 1) * OUTPUT_SIZE + TRANSACTION_OVERHEAD; - fee = Math.ceil(estimatedSize * feeRate); + let change = inputSum - totalOutput - BigInt(fee); - if (inputSum >= totalOutput + BigInt(fee)) { - break; + if (change > ZERO && Number(change) < DUST_THRESHOLD) { + const { change: newChange, fee: feeDelta } = this.attemptDustRescue({ + change, + feeRate, + selected, + remaining: utxos.slice(selected.length), + inputSum, + totalOutput, + outputsCount: outputs.length + }); + change = newChange; + fee += feeDelta; + + if (change < BigInt(DUST_THRESHOLD)) { + fee += Number(change); + change = ZERO; } } - if (inputSum < totalOutput + BigInt(fee)) { - return undefined; + const finalOutputs = outputs.map(({ address, value }) => ({ address, value: Number(value) })); + + if (change >= BigInt(DUST_THRESHOLD)) { + finalOutputs.push({ address: changeAddress, value: Number(change) }); } - const change = inputSum - totalOutput - BigInt(fee); + return { selectedUTxOs: selected, outputs: finalOutputs, fee }; + } + + /** Estimate the fee for a given input / output count */ + private computeFee(inputCount: number, outputCount: number, feeRate: number): number { + const size = inputCount * INPUT_SIZE + outputCount * OUTPUT_SIZE + TRANSACTION_OVERHEAD; + return Math.ceil(size * feeRate); + } - const coinselectOutputs: { address: string; value: number }[] = outputs.map((o) => ({ - address: o.address, - value: Number(o.value) - })); + /** + * Try to pull one more UTxO so that change exceeds the dust threshold if + * the value rescued is worth more than the added fee. + */ + private attemptDustRescue({ + change, + feeRate, + selected, + remaining, + inputSum, + totalOutput, + outputsCount + }: { + change: bigint; + feeRate: number; + selected: UTxO[]; + remaining: UTxO[]; + inputSum: bigint; + totalOutput: bigint; + outputsCount: number; + }): { change: bigint; fee: number } { + if (change === ZERO || Number(change) >= DUST_THRESHOLD) return { change, fee: 0 }; - if (change > BigInt(0)) { - if (Number(change) < DUST_THRESHOLD) { - fee += Number(change); - } else { - coinselectOutputs.push({ address: changeAddress, value: Number(change) }); + const originalChange = change; + let feeDelta = 0; + + for (const utxo of remaining) { + const newInputSum = inputSum + utxo.satoshis; + const newFee = this.computeFee(selected.length + 1, outputsCount + 1, feeRate); + const newChange = newInputSum - totalOutput - BigInt(newFee); + + const rescued = newChange - originalChange; + const extraCost = BigInt(Math.ceil(INPUT_SIZE * feeRate)); + + if (rescued >= extraCost && newChange >= BigInt(DUST_THRESHOLD)) { + selected.push(utxo); + feeDelta = newFee - this.computeFee(selected.length - 1, outputsCount + 1, feeRate); + return { change: newChange, fee: feeDelta }; } } - return { selectedUTxOs, outputs: coinselectOutputs, fee }; + return { change, fee: 0 }; } } diff --git a/packages/bitcoin/src/wallet/lib/wallet/MaestroFeeMarketProvider.ts b/packages/bitcoin/src/wallet/lib/wallet/MaestroFeeMarketProvider.ts index a3b69af71..a069e38c3 100644 --- a/packages/bitcoin/src/wallet/lib/wallet/MaestroFeeMarketProvider.ts +++ b/packages/bitcoin/src/wallet/lib/wallet/MaestroFeeMarketProvider.ts @@ -5,7 +5,7 @@ import { MaestroBitcoinDataProvider } from '../providers/MaestroBitcoinDataProvi import { Network } from '../common/network'; import { Logger } from 'ts-log'; import { FeeMarketProvider } from './FeeMarketProvider'; -import { DEFAULT_MARKETS } from './constants'; +import { DEFAULT_MARKETS, MIN_FEE_RATE } from './constants'; export class MaestroFeeMarketProvider implements FeeMarketProvider { constructor( @@ -26,15 +26,15 @@ export class MaestroFeeMarketProvider implements FeeMarketProvider { return { fast: { - feeRate: fastEstimate.feeRate, + feeRate: Math.max(fastEstimate.feeRate, MIN_FEE_RATE), targetConfirmationTime: fastEstimate.blocks * 10 * 60 }, standard: { - feeRate: standardEstimate.feeRate, + feeRate: Math.max(standardEstimate.feeRate, MIN_FEE_RATE), targetConfirmationTime: standardEstimate.blocks * 10 * 60 }, slow: { - feeRate: slowEstimate.feeRate, + feeRate: Math.max(slowEstimate.feeRate, MIN_FEE_RATE), targetConfirmationTime: slowEstimate.blocks * 10 * 60 } }; diff --git a/packages/bitcoin/src/wallet/lib/wallet/MempoolSpaceMarketProvider.ts b/packages/bitcoin/src/wallet/lib/wallet/MempoolSpaceMarketProvider.ts index df133f0a5..bebe62ff4 100644 --- a/packages/bitcoin/src/wallet/lib/wallet/MempoolSpaceMarketProvider.ts +++ b/packages/bitcoin/src/wallet/lib/wallet/MempoolSpaceMarketProvider.ts @@ -4,7 +4,7 @@ import { Network } from '../common/network'; import { FeeMarketProvider } from './FeeMarketProvider'; import axios, { AxiosInstance } from 'axios'; import { Logger } from 'ts-log'; -import { DEFAULT_MARKETS } from './constants'; +import { DEFAULT_MARKETS, MIN_FEE_RATE } from './constants'; const satsPerVByteToBtcPerKB = (satsPerVByte: number): number => (satsPerVByte * 1000) / 100_000_000; @@ -31,15 +31,15 @@ export class MempoolSpaceMarketProvider implements FeeMarketProvider { return { fast: { - feeRate: satsPerVByteToBtcPerKB(fastEstimate), + feeRate: Math.max(satsPerVByteToBtcPerKB(fastEstimate), MIN_FEE_RATE), targetConfirmationTime: 600 // 10 minutes }, standard: { - feeRate: satsPerVByteToBtcPerKB(standardEstimate), + feeRate: Math.max(satsPerVByteToBtcPerKB(standardEstimate), MIN_FEE_RATE), targetConfirmationTime: 1800 // 30 minutes }, slow: { - feeRate: satsPerVByteToBtcPerKB(slowEstimate), + feeRate: Math.max(satsPerVByteToBtcPerKB(slowEstimate), MIN_FEE_RATE), targetConfirmationTime: 3600 // 60 minutes } }; diff --git a/packages/bitcoin/src/wallet/lib/wallet/constants.ts b/packages/bitcoin/src/wallet/lib/wallet/constants.ts index b065e2a2b..3325bff42 100644 --- a/packages/bitcoin/src/wallet/lib/wallet/constants.ts +++ b/packages/bitcoin/src/wallet/lib/wallet/constants.ts @@ -1,3 +1,5 @@ +export const MIN_FEE_RATE = 0.000_011; + export const DEFAULT_MARKETS = { fast: { feeRate: 0.000_025, @@ -8,7 +10,7 @@ export const DEFAULT_MARKETS = { targetConfirmationTime: 3 }, slow: { - feeRate: 0.000_01, + feeRate: 0.000_011, targetConfirmationTime: 6 } }; diff --git a/packages/bitcoin/test/GreedyInputSelector.test.ts b/packages/bitcoin/test/GreedyInputSelector.test.ts index d524e4518..7d8281f6b 100644 --- a/packages/bitcoin/test/GreedyInputSelector.test.ts +++ b/packages/bitcoin/test/GreedyInputSelector.test.ts @@ -1,12 +1,13 @@ /* 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 */ import { GreedyInputSelector } from '../src/wallet/lib/input-selection/GreedyInputSelector'; +import { DUST_THRESHOLD } from '../src/wallet/lib/common'; describe('GreedyInputSelector', () => { const selector = new GreedyInputSelector(); const feeRate = 1; // sat/byte const changeAddress = 'change-address'; - const createUTXO = (satoshis: bigint, id = Math.random().toString()) => ({ + const createUTxO = (satoshis: bigint, id = Math.random().toString()) => ({ txId: id, index: 0, address: 'address1', @@ -14,8 +15,8 @@ describe('GreedyInputSelector', () => { satoshis }); - it('selects sufficient UTXOs and returns change', () => { - const utxos = [createUTXO(BigInt(5000)), createUTXO(BigInt(8000))]; + it('selects sufficient UTxOs and returns change', () => { + const utxos = [createUTxO(BigInt(5000)), createUTxO(BigInt(8000))]; const outputs = [{ address: 'address1', value: BigInt(4000) }]; const result = selector.selectInputs(changeAddress, utxos, outputs, feeRate); @@ -26,7 +27,7 @@ describe('GreedyInputSelector', () => { }); it('returns undefined if inputs are insufficient', () => { - const utxos = [createUTXO(BigInt(1000))]; + const utxos = [createUTxO(BigInt(1000))]; const outputs = [{ address: 'address1', value: BigInt(2000) }]; const result = selector.selectInputs(changeAddress, utxos, outputs, feeRate); @@ -34,7 +35,7 @@ describe('GreedyInputSelector', () => { }); it('includes all outputs and correct change when no dust', () => { - const utxos = [createUTXO(BigInt(10_000))]; + const utxos = [createUTxO(BigInt(10_000))]; const outputs = [ { address: 'address1', value: BigInt(3000) }, { address: 'address2', value: BigInt(2000) } @@ -48,7 +49,7 @@ describe('GreedyInputSelector', () => { }); it('adds dust change to fee instead of outputting it', () => { - const utxos = [createUTXO(BigInt(6000))]; + const utxos = [createUTxO(BigInt(6000))]; const outputs = [{ address: 'address1', value: BigInt(5500) }]; const result = selector.selectInputs(changeAddress, utxos, outputs, feeRate); @@ -58,4 +59,39 @@ describe('GreedyInputSelector', () => { const sumInputs = result!.selectedUTxOs.reduce((acc, u) => acc + Number(u.satoshis), 0); expect(sumInputs - sumOutputs - result!.fee).toBe(0); }); + + it('rescues dust by including an extra UTxO when it is economical', () => { + const utxos = [createUTxO(BigInt(6000), 'utxo1'), createUTxO(BigInt(1000), 'utxo2')]; + const outputs = [{ address: 'dest1', value: BigInt(5500) }]; + + const result = selector.selectInputs(changeAddress, utxos, outputs, feeRate); + + expect(result).toBeDefined(); + // Should include both inputs because rescuing dust is profitable. + expect(result!.selectedUTxOs.map((u) => u.txId)).toEqual(['utxo1', 'utxo2']); + + // Change output must be present and above the dust threshold. + const changeOutput = result!.outputs.find((o) => o.address === changeAddress); + expect(changeOutput).toBeDefined(); + expect(changeOutput!.value).toBeGreaterThanOrEqual(DUST_THRESHOLD); + }); + + it('does NOT rescue dust when the extra input would cost more than the value rescued', () => { + const bigFeeRate = 5; + // Single UTxO leaves ~290 sats dust (below dust threshold). + // Adding the second UTxO would cost ~340 sats in extra fee (68vB * 5) but + // only rescue ~260 sats, so it should NOT be added. + const utxos = [createUTxO(BigInt(9000), 'big_utxo'), createUTxO(BigInt(600), 'small_utxo')]; + const outputs = [{ address: 'dest1', value: BigInt(8000) }]; + + const result = selector.selectInputs(changeAddress, utxos, outputs, bigFeeRate); + + expect(result).toBeDefined(); + // Only the first (large) UTxO should be used. + expect(result!.selectedUTxOs.length).toBe(1); + expect(result!.selectedUTxOs[0].txId).toBe('big_utxo'); + + // No change output; dust was added to the fee. + expect(result!.outputs.some((o) => o.address === changeAddress)).toBe(false); + }); }); From 13dabdbfbd9a499f7a2483bb00e1a4e29e02d196 Mon Sep 17 00:00:00 2001 From: Angel Castillo Date: Wed, 2 Jul 2025 15:25:13 +0800 Subject: [PATCH 2/2] fix: rename bitcoin fee rate label from fast to High --- .../views/bitcoin-mode/features/send/components/SendStepOne.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/browser-extension-wallet/src/views/bitcoin-mode/features/send/components/SendStepOne.tsx b/apps/browser-extension-wallet/src/views/bitcoin-mode/features/send/components/SendStepOne.tsx index 2c9f9ee70..c6d9c2439 100644 --- a/apps/browser-extension-wallet/src/views/bitcoin-mode/features/send/components/SendStepOne.tsx +++ b/apps/browser-extension-wallet/src/views/bitcoin-mode/features/send/components/SendStepOne.tsx @@ -32,7 +32,7 @@ interface RecommendedFee { } const fees: RecommendedFee[] = [ - { key: 'fast', label: 'Fast', feeRate: 10, estimatedTime: '~10 min' }, + { key: 'fast', label: 'High', feeRate: 10, estimatedTime: '~10 min' }, { key: 'standard', label: 'Average', feeRate: 5, estimatedTime: '~30 min' }, { key: 'slow', label: 'Slow', feeRate: 1, estimatedTime: '~60 min' }, { key: 'custom', label: 'Custom', estimatedTime: '~?? min' }