Skip to content

Commit fd324bf

Browse files
fix: bitcoin input selector now tries to rescue dust if possible
1 parent 0fe45de commit fd324bf

File tree

5 files changed

+134
-39
lines changed

5 files changed

+134
-39
lines changed

packages/bitcoin/src/wallet/lib/input-selection/GreedyInputSelector.ts

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,50 +7,103 @@ import { InputSelector } from './InputSelector';
77
* A concrete implementation of InputSelector that uses a simple greedy algorithm.
88
*/
99
export class GreedyInputSelector implements InputSelector {
10-
selectInputs(
10+
public selectInputs(
1111
changeAddress: string,
1212
utxos: UTxO[],
1313
outputs: { address: string; value: bigint }[],
1414
feeRate: number
1515
): { selectedUTxOs: UTxO[]; outputs: { address: string; value: number }[]; fee: number } | undefined {
16-
const selectedUTxOs: UTxO[] = [];
16+
const selected: UTxO[] = [];
17+
const totalOutput = outputs.reduce((acc, o) => acc + o.value, BigInt(0));
1718
let inputSum = BigInt(0);
1819
let fee = 0;
1920

20-
const totalOutput: bigint = outputs.reduce((acc, out) => acc + out.value, BigInt(0));
21-
2221
for (const utxo of utxos) {
23-
selectedUTxOs.push(utxo);
22+
selected.push(utxo);
2423
inputSum += utxo.satoshis;
24+
fee = this.computeFee(selected.length, outputs.length + 1, feeRate);
25+
if (inputSum >= totalOutput + BigInt(fee)) break;
26+
}
27+
28+
if (inputSum < totalOutput + BigInt(fee)) return undefined;
29+
30+
let change = inputSum - totalOutput - BigInt(fee);
2531

26-
const estimatedSize =
27-
selectedUTxOs.length * INPUT_SIZE + (outputs.length + 1) * OUTPUT_SIZE + TRANSACTION_OVERHEAD;
28-
fee = Math.ceil(estimatedSize * feeRate);
32+
if (change > BigInt(0) && Number(change) < DUST_THRESHOLD) {
33+
const { change: newChange, fee: feeDelta } = this.attemptDustRescue({
34+
change,
35+
feeRate,
36+
selected,
37+
remaining: utxos.slice(selected.length),
38+
inputSum,
39+
totalOutput,
40+
outputsCount: outputs.length
41+
});
42+
change = newChange;
43+
fee += feeDelta;
2944

30-
if (inputSum >= totalOutput + BigInt(fee)) {
31-
break;
45+
if (change < BigInt(DUST_THRESHOLD)) {
46+
fee += Number(change);
47+
change = BigInt(0);
3248
}
3349
}
3450

35-
if (inputSum < totalOutput + BigInt(fee)) {
36-
return undefined;
51+
const finalOutputs = outputs.map(({ address, value }) => ({ address, value: Number(value) }));
52+
53+
if (change >= BigInt(DUST_THRESHOLD)) {
54+
finalOutputs.push({ address: changeAddress, value: Number(change) });
3755
}
3856

39-
const change = inputSum - totalOutput - BigInt(fee);
57+
return { selectedUTxOs: selected, outputs: finalOutputs, fee };
58+
}
4059

41-
const coinselectOutputs: { address: string; value: number }[] = outputs.map((o) => ({
42-
address: o.address,
43-
value: Number(o.value)
44-
}));
60+
/** Estimate the fee for a given input / output count */
61+
private computeFee(inputCount: number, outputCount: number, feeRate: number): number {
62+
const size = inputCount * INPUT_SIZE + outputCount * OUTPUT_SIZE + TRANSACTION_OVERHEAD;
63+
return Math.ceil(size * feeRate);
64+
}
4565

46-
if (change > BigInt(0)) {
47-
if (Number(change) < DUST_THRESHOLD) {
48-
fee += Number(change);
49-
} else {
50-
coinselectOutputs.push({ address: changeAddress, value: Number(change) });
66+
/**
67+
* Try to pull one more UtXO so that change exceeds the dust threshold if
68+
* the value rescued is worth more than the added fee.
69+
*/
70+
private attemptDustRescue({
71+
change,
72+
feeRate,
73+
selected,
74+
remaining,
75+
inputSum,
76+
totalOutput,
77+
outputsCount
78+
}: {
79+
change: bigint;
80+
feeRate: number;
81+
selected: UTxO[];
82+
remaining: UTxO[];
83+
inputSum: bigint;
84+
totalOutput: bigint;
85+
outputsCount: number;
86+
}): { change: bigint; fee: number } {
87+
if (change === BigInt(0) || Number(change) >= DUST_THRESHOLD) return { change, fee: 0 };
88+
89+
const originalChange = change;
90+
let feeDelta = 0;
91+
92+
for (const utxo of remaining) {
93+
const newInputSum = inputSum + utxo.satoshis;
94+
const newFee = this.computeFee(selected.length + 1, outputsCount + 1, feeRate);
95+
const newChange = newInputSum - totalOutput - BigInt(newFee);
96+
97+
const rescued = newChange - originalChange;
98+
const extraCost = BigInt(Math.ceil(INPUT_SIZE * feeRate));
99+
100+
if (rescued >= extraCost && newChange >= BigInt(DUST_THRESHOLD)) {
101+
selected.push(utxo);
102+
feeDelta = newFee - this.computeFee(selected.length - 1, outputsCount + 1, feeRate);
103+
return { change: newChange, fee: feeDelta };
51104
}
52105
}
53106

54-
return { selectedUTxOs, outputs: coinselectOutputs, fee };
107+
return { change, fee: 0 };
55108
}
56109
}

packages/bitcoin/src/wallet/lib/wallet/MaestroFeeMarketProvider.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { MaestroBitcoinDataProvider } from '../providers/MaestroBitcoinDataProvi
55
import { Network } from '../common/network';
66
import { Logger } from 'ts-log';
77
import { FeeMarketProvider } from './FeeMarketProvider';
8-
import { DEFAULT_MARKETS } from './constants';
8+
import { DEFAULT_MARKETS, MIN_FEE_RATE } from './constants';
99

1010
export class MaestroFeeMarketProvider implements FeeMarketProvider {
1111
constructor(
@@ -26,15 +26,15 @@ export class MaestroFeeMarketProvider implements FeeMarketProvider {
2626

2727
return {
2828
fast: {
29-
feeRate: fastEstimate.feeRate,
29+
feeRate: Math.max(fastEstimate.feeRate, MIN_FEE_RATE),
3030
targetConfirmationTime: fastEstimate.blocks * 10 * 60
3131
},
3232
standard: {
33-
feeRate: standardEstimate.feeRate,
33+
feeRate: Math.max(standardEstimate.feeRate, MIN_FEE_RATE),
3434
targetConfirmationTime: standardEstimate.blocks * 10 * 60
3535
},
3636
slow: {
37-
feeRate: slowEstimate.feeRate,
37+
feeRate: Math.max(slowEstimate.feeRate, MIN_FEE_RATE),
3838
targetConfirmationTime: slowEstimate.blocks * 10 * 60
3939
}
4040
};

packages/bitcoin/src/wallet/lib/wallet/MempoolSpaceMarketProvider.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@ import { Network } from '../common/network';
44
import { FeeMarketProvider } from './FeeMarketProvider';
55
import axios, { AxiosInstance } from 'axios';
66
import { Logger } from 'ts-log';
7-
import { DEFAULT_MARKETS } from './constants';
7+
import { DEFAULT_MARKETS, MIN_FEE_RATE } from './constants';
88

99
const satsPerVByteToBtcPerKB = (satsPerVByte: number): number => (satsPerVByte * 1000) / 100_000_000;
1010

1111
export class MempoolSpaceMarketProvider implements FeeMarketProvider {
1212
private readonly api: AxiosInstance;
1313

14-
constructor(url: string, private readonly logger: Logger, private readonly network: Network = Network.Mainnet) {
14+
constructor(
15+
url: string,
16+
private readonly logger: Logger,
17+
private readonly network: Network = Network.Mainnet
18+
) {
1519
this.api = axios.create({
1620
baseURL: url
1721
});
@@ -31,15 +35,15 @@ export class MempoolSpaceMarketProvider implements FeeMarketProvider {
3135

3236
return {
3337
fast: {
34-
feeRate: satsPerVByteToBtcPerKB(fastEstimate),
38+
feeRate: Math.max(satsPerVByteToBtcPerKB(fastEstimate), MIN_FEE_RATE),
3539
targetConfirmationTime: 600 // 10 minutes
3640
},
3741
standard: {
38-
feeRate: satsPerVByteToBtcPerKB(standardEstimate),
42+
feeRate: Math.max(satsPerVByteToBtcPerKB(standardEstimate), MIN_FEE_RATE),
3943
targetConfirmationTime: 1800 // 30 minutes
4044
},
4145
slow: {
42-
feeRate: satsPerVByteToBtcPerKB(slowEstimate),
46+
feeRate: Math.max(satsPerVByteToBtcPerKB(slowEstimate), MIN_FEE_RATE),
4347
targetConfirmationTime: 3600 // 60 minutes
4448
}
4549
};

packages/bitcoin/src/wallet/lib/wallet/constants.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export const MIN_FEE_RATE = 0.000_011;
2+
13
export const DEFAULT_MARKETS = {
24
fast: {
35
feeRate: 0.000_025,
@@ -8,7 +10,7 @@ export const DEFAULT_MARKETS = {
810
targetConfirmationTime: 3
911
},
1012
slow: {
11-
feeRate: 0.000_01,
13+
feeRate: 0.000_011,
1214
targetConfirmationTime: 6
1315
}
1416
};

packages/bitcoin/test/GreedyInputSelector.test.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
/* 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 */
22
import { GreedyInputSelector } from '../src/wallet/lib/input-selection/GreedyInputSelector';
3+
import { DUST_THRESHOLD } from '../src/wallet/lib/common';
34

45
describe('GreedyInputSelector', () => {
56
const selector = new GreedyInputSelector();
67
const feeRate = 1; // sat/byte
78
const changeAddress = 'change-address';
89

9-
const createUTXO = (satoshis: bigint, id = Math.random().toString()) => ({
10+
const createUtXO = (satoshis: bigint, id = Math.random().toString()) => ({
1011
txId: id,
1112
index: 0,
1213
address: 'address1',
1314
vout: 0,
1415
satoshis
1516
});
1617

17-
it('selects sufficient UTXOs and returns change', () => {
18-
const utxos = [createUTXO(BigInt(5000)), createUTXO(BigInt(8000))];
18+
it('selects sufficient UtXOs and returns change', () => {
19+
const utxos = [createUtXO(BigInt(5000)), createUtXO(BigInt(8000))];
1920
const outputs = [{ address: 'address1', value: BigInt(4000) }];
2021
const result = selector.selectInputs(changeAddress, utxos, outputs, feeRate);
2122

@@ -26,15 +27,15 @@ describe('GreedyInputSelector', () => {
2627
});
2728

2829
it('returns undefined if inputs are insufficient', () => {
29-
const utxos = [createUTXO(BigInt(1000))];
30+
const utxos = [createUtXO(BigInt(1000))];
3031
const outputs = [{ address: 'address1', value: BigInt(2000) }];
3132
const result = selector.selectInputs(changeAddress, utxos, outputs, feeRate);
3233

3334
expect(result).toBeUndefined();
3435
});
3536

3637
it('includes all outputs and correct change when no dust', () => {
37-
const utxos = [createUTXO(BigInt(10_000))];
38+
const utxos = [createUtXO(BigInt(10_000))];
3839
const outputs = [
3940
{ address: 'address1', value: BigInt(3000) },
4041
{ address: 'address2', value: BigInt(2000) }
@@ -48,7 +49,7 @@ describe('GreedyInputSelector', () => {
4849
});
4950

5051
it('adds dust change to fee instead of outputting it', () => {
51-
const utxos = [createUTXO(BigInt(6000))];
52+
const utxos = [createUtXO(BigInt(6000))];
5253
const outputs = [{ address: 'address1', value: BigInt(5500) }];
5354
const result = selector.selectInputs(changeAddress, utxos, outputs, feeRate);
5455

@@ -58,4 +59,39 @@ describe('GreedyInputSelector', () => {
5859
const sumInputs = result!.selectedUTxOs.reduce((acc, u) => acc + Number(u.satoshis), 0);
5960
expect(sumInputs - sumOutputs - result!.fee).toBe(0);
6061
});
62+
63+
it('rescues dust by including an extra UtXO when it is economical', () => {
64+
const utxos = [createUtXO(BigInt(6000), 'utxo1'), createUtXO(BigInt(1000), 'utxo2')];
65+
const outputs = [{ address: 'dest1', value: BigInt(5500) }];
66+
67+
const result = selector.selectInputs(changeAddress, utxos, outputs, feeRate);
68+
69+
expect(result).toBeDefined();
70+
// Should include both inputs because rescuing dust is profitable.
71+
expect(result!.selectedUTxOs.map((u) => u.txId)).toEqual(['utxo1', 'utxo2']);
72+
73+
// Change output must be present and above the dust threshold.
74+
const changeOutput = result!.outputs.find((o) => o.address === changeAddress);
75+
expect(changeOutput).toBeDefined();
76+
expect(changeOutput!.value).toBeGreaterThanOrEqual(DUST_THRESHOLD);
77+
});
78+
79+
it('does NOT rescue dust when the extra input would cost more than the value rescued', () => {
80+
const bigFeeRate = 5;
81+
// Single UtXO leaves ~290 sats dust (below dust threshold).
82+
// Adding the second UtXO would cost ~340 sats in extra fee (68vB * 5) but
83+
// only rescue ~260 sats, so it should NOT be added.
84+
const utxos = [createUtXO(BigInt(9000), 'big_utxo'), createUtXO(BigInt(600), 'small_utxo')];
85+
const outputs = [{ address: 'dest1', value: BigInt(8000) }];
86+
87+
const result = selector.selectInputs(changeAddress, utxos, outputs, bigFeeRate);
88+
89+
expect(result).toBeDefined();
90+
// Only the first (large) UtXO should be used.
91+
expect(result!.selectedUTxOs.length).toBe(1);
92+
expect(result!.selectedUTxOs[0].txId).toBe('big_utxo');
93+
94+
// No change output; dust was added to the fee.
95+
expect(result!.outputs.some((o) => o.address === changeAddress)).toBe(false);
96+
});
6197
});

0 commit comments

Comments
 (0)