Skip to content

Commit

Permalink
fix: filter out uneconomical utxos, closes #4505
Browse files Browse the repository at this point in the history
  • Loading branch information
edgarkhanzadian committed Jan 24, 2024
1 parent be0d9a7 commit a6e116c
Show file tree
Hide file tree
Showing 7 changed files with 291 additions and 74 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe(determineUtxosForSpend.name, () => {
describe('sorting algorithm (biggest first and no dust)', () => {
test('that it filters out dust utxos', () => {
const result = generate10kSpendWithTestData('tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m');
const hasDust = result.orderedUtxos.some(utxo => utxo.value <= BTC_P2WPKH_DUST_AMOUNT);
const hasDust = result.filteredUtxos.some(utxo => utxo.value <= BTC_P2WPKH_DUST_AMOUNT);
expect(hasDust).toBeFalsy();
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { getAddressInfo, validate } from 'bitcoin-address-validation';

import { BTC_P2WPKH_DUST_AMOUNT } from '@shared/constants';
import { validate } from 'bitcoin-address-validation';

import { UtxoResponseItem } from '@app/query/bitcoin/bitcoin-client';

import { BtcSizeFeeEstimator } from '../fees/btc-size-fee-estimator';
import { filterUneconomicalUtxos, getSizeInfo } from '../utils';

export interface DetermineUtxosForSpendArgs {
amount: number;
Expand All @@ -20,17 +18,12 @@ export function determineUtxosForSpendAll({
utxos,
}: DetermineUtxosForSpendArgs) {
if (!validate(recipient)) throw new Error('Cannot calculate spend of invalid address type');
const filteredUtxos = filterUneconomicalUtxos({ utxos, feeRate, address: recipient });

const addressInfo = getAddressInfo(recipient);

const txSizer = new BtcSizeFeeEstimator();

const filteredUtxos = utxos.filter(utxo => utxo.value >= BTC_P2WPKH_DUST_AMOUNT);

const sizeInfo = txSizer.calcTxSize({
input_script: 'p2wpkh',
input_count: filteredUtxos.length,
[addressInfo.type + '_output_count']: 1,
const sizeInfo = getSizeInfo({
inputLength: filteredUtxos.length,
outputLength: 1,
recipient,
});

// Fee has already been deducted from the amount with send all
Expand All @@ -54,25 +47,23 @@ export function determineUtxosForSpend({
}: DetermineUtxosForSpendArgs) {
if (!validate(recipient)) throw new Error('Cannot calculate spend of invalid address type');

const addressInfo = getAddressInfo(recipient);

const orderedUtxos = utxos
.filter(utxo => utxo.value >= BTC_P2WPKH_DUST_AMOUNT)
.sort((a, b) => b.value - a.value);
const orderedUtxos = utxos.sort((a, b) => b.value - a.value);

const txSizer = new BtcSizeFeeEstimator();
const filteredUtxos = filterUneconomicalUtxos({
utxos: orderedUtxos,
feeRate,
address: recipient,
});

const neededUtxos = [];
let sum = 0n;
let sizeInfo = null;

for (const utxo of orderedUtxos) {
sizeInfo = txSizer.calcTxSize({
// Only p2wpkh is supported by the wallet
input_script: 'p2wpkh',
input_count: neededUtxos.length,
// From the address of the recipient, we infer the output type
[addressInfo.type + '_output_count']: 2,
for (const utxo of filteredUtxos) {
sizeInfo = getSizeInfo({
inputLength: neededUtxos.length,
outputLength: 2,
recipient,
});
if (sum >= BigInt(amount) + BigInt(Math.ceil(sizeInfo.txVBytes * feeRate))) break;

Expand All @@ -92,7 +83,7 @@ export function determineUtxosForSpend({
];

return {
orderedUtxos,
filteredUtxos,
inputs: neededUtxos,
outputs,
size: sizeInfo.txVBytes,
Expand Down
143 changes: 143 additions & 0 deletions src/app/common/transactions/bitcoin/fees/bitcoin-fees.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import BigNumber from 'bignumber.js';
import { sha256 } from 'bitcoinjs-lib/src/crypto';

import { UtxoResponseItem } from '@app/query/bitcoin/bitcoin-client';

import { filterUneconomicalUtxos } from '../utils';
import { calculateMaxBitcoinSpend } from './calculate-max-bitcoin-spend';

function generateTxId(value: number): UtxoResponseItem {
const buffer = Buffer.from(Math.random().toString());
return {
txid: sha256(sha256(buffer)).toString(),
vout: 0,
status: {
confirmed: true,
block_height: 2568495,
block_hash: '000000000000008622fafce4a5388861b252d534f819d0f7cb5d4f2c5f9c1638',
block_time: 1703787327,
},
value,
};
}

function generateTransactions(values: number[]) {
return values.map(val => generateTxId(val));
}

function generateAverageFee(value: number) {
return {
hourFee: BigNumber(value / 2),
halfHourFee: BigNumber(value),
fastestFee: BigNumber(value * 2),
};
}

describe(calculateMaxBitcoinSpend.name, () => {
const utxos = generateTransactions([600, 600, 1200, 1200, 10000, 10000, 25000, 40000, 50000000]);

test('with 1 sat/vb fee', () => {
const fee = 1;
const maxBitcoinSpend = calculateMaxBitcoinSpend({
address: '',
utxos,
fetchedFeeRates: generateAverageFee(fee),
});
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50087948);
});

test('with 5 sat/vb fee', () => {
const fee = 5;
const maxBitcoinSpend = calculateMaxBitcoinSpend({
address: '',
utxos,
fetchedFeeRates: generateAverageFee(fee),
});
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50085342);
});

test('with 30 sat/vb fee', () => {
const fee = 30;
const maxBitcoinSpend = calculateMaxBitcoinSpend({
address: '',
utxos,
fetchedFeeRates: generateAverageFee(fee),
});
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50073585);
});

test('with 100 sat/vb fee', () => {
const fee = 100;
const maxBitcoinSpend = calculateMaxBitcoinSpend({
address: '',
utxos,
fetchedFeeRates: generateAverageFee(fee),
});
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50046950);
});

test('with 400 sat/vb fee', () => {
const fee = 400;
const maxBitcoinSpend = calculateMaxBitcoinSpend({
address: '',
utxos,
fetchedFeeRates: generateAverageFee(fee),
});
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(49969100);
});
});

describe(filterUneconomicalUtxos.name, () => {
const utxos = generateTransactions([600, 600, 1200, 1200, 10000, 10000, 25000, 40000, 50000000]);

test('with 1 sat/vb fee', () => {
const fee = 1;
const filteredUtxos = filterUneconomicalUtxos({
address: '',
utxos,
feeRate: fee,
});

expect(filteredUtxos.length).toEqual(9);
});

test('with 10 sat/vb fee', () => {
const fee = 10;
const filteredUtxos = filterUneconomicalUtxos({
address: '',
utxos,
feeRate: fee,
});
expect(filteredUtxos.length).toEqual(7);
});

test('with 30 sat/vb fee', () => {
const fee = 30;
const filteredUtxos = filterUneconomicalUtxos({
address: '',
utxos,
feeRate: fee,
});
expect(filteredUtxos.length).toEqual(5);
});

test('with 200 sat/vb fee', () => {
const fee = 200;
const filteredUtxos = filterUneconomicalUtxos({
address: '',
utxos,
feeRate: fee,
});
expect(filteredUtxos.length).toEqual(3);
});

test('with 400 sat/vb fee', () => {
const fee = 400;
const filteredUtxos = filterUneconomicalUtxos({
address: '',
utxos,
feeRate: fee,
});
expect(filteredUtxos.length).toEqual(2);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type InputScriptTypes =
| 'p2wsh'
| 'p2tr';

interface Params {
interface TxSizerParams {
input_count: number;
input_script: InputScriptTypes;
input_m: number;
Expand Down Expand Up @@ -48,7 +48,7 @@ export class BtcSizeFeeEstimator {
'p2tr',
];

defaultParams: Params = {
defaultParams: TxSizerParams = {
input_count: 0,
input_script: 'p2wpkh',
input_m: 0,
Expand All @@ -62,7 +62,7 @@ export class BtcSizeFeeEstimator {
p2tr_output_count: 0,
};

params: Params = { ...this.defaultParams };
params: TxSizerParams = { ...this.defaultParams };

getSizeOfScriptLengthElement(length: number) {
if (length < 75) {
Expand Down Expand Up @@ -128,7 +128,7 @@ export class BtcSizeFeeEstimator {
return witness_vbytes * 3;
}

prepareParams(opts: Partial<Params>) {
prepareParams(opts: Partial<TxSizerParams>) {
// Verify opts and set them to this.params
opts = opts || Object.assign(this.defaultParams);

Expand Down Expand Up @@ -279,7 +279,7 @@ export class BtcSizeFeeEstimator {
};
}

calcTxSize(opts: Partial<Params>) {
calcTxSize(opts: Partial<TxSizerParams>) {
this.prepareParams(opts);
const output_count = this.getOutputCount();
const { inputSize, inputWitnessSize } = this.getSizeBasedOnInputType();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import BigNumber from 'bignumber.js';

import { AverageBitcoinFeeRates } from '@shared/models/fees/bitcoin-fees.model';
import { createMoney } from '@shared/models/money.model';

import { satToBtc } from '@app/common/money/unit-conversion';
import { UtxoResponseItem } from '@app/query/bitcoin/bitcoin-client';

import { filterUneconomicalUtxos, getSpendableAmount } from '../utils';

interface CalculateMaxBitcoinSpend {
address: string;
utxos: UtxoResponseItem[];
fetchedFeeRates?: AverageBitcoinFeeRates;
feeRate?: number;
}

export function calculateMaxBitcoinSpend({
address,
utxos,
feeRate,
fetchedFeeRates,
}: CalculateMaxBitcoinSpend) {
if (!utxos.length || !fetchedFeeRates)
return {
spendAllFee: 0,
amount: createMoney(0, 'BTC'),
spendableBitcoin: new BigNumber(0),
};

const currentFeeRate = feeRate ?? fetchedFeeRates.halfHourFee.toNumber();

const filteredUtxos = filterUneconomicalUtxos({
utxos,
feeRate: currentFeeRate,
address,
});

const { spendableAmount, fee } = getSpendableAmount({
utxos: filteredUtxos,
feeRate: currentFeeRate,
address,
});

return {
spendAllFee: fee,
amount: createMoney(spendableAmount, 'BTC'),
spendableBitcoin: satToBtc(spendableAmount),
};
}
Loading

0 comments on commit a6e116c

Please sign in to comment.