Skip to content

Feat/lw 13152 fee computation fixes #1933

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 8, 2025
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ 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: 'Low', feeRate: 1, estimatedTime: '~60 min' },
{ key: 'slow', label: 'Slow', feeRate: 1, estimatedTime: '~60 min' },
{ key: 'custom', label: 'Custom', estimatedTime: '~?? min' }
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
}
};
Expand Down
4 changes: 3 additions & 1 deletion packages/bitcoin/src/wallet/lib/wallet/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const MIN_FEE_RATE = 0.000_011;

export const DEFAULT_MARKETS = {
fast: {
feeRate: 0.000_025,
Expand All @@ -8,7 +10,7 @@ export const DEFAULT_MARKETS = {
targetConfirmationTime: 3
},
slow: {
feeRate: 0.000_01,
feeRate: 0.000_011,
targetConfirmationTime: 6
}
};
48 changes: 42 additions & 6 deletions packages/bitcoin/test/GreedyInputSelector.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
/* 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',
vout: 0,
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);

Expand All @@ -26,15 +27,15 @@ 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);

expect(result).toBeUndefined();
});

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) }
Expand All @@ -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);

Expand All @@ -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);
});
});
Loading