Skip to content

Commit

Permalink
feat: improve fee calculation accuracy for P2WPKH inputs
Browse files Browse the repository at this point in the history
  • Loading branch information
ShookLyngs committed Sep 8, 2024
1 parent ebcefc3 commit 9374f41
Show file tree
Hide file tree
Showing 2 changed files with 92 additions and 12 deletions.
65 changes: 53 additions & 12 deletions packages/btc/src/transaction/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { dataToOpReturnScriptPubkey, isOpReturnScriptPubkey } from './embed';
import { networkTypeToConfig } from '../preset/config';
import { BaseOutput, Utxo, utxoToInput } from './utxo';
import { limitPromiseBatchSize } from '../utils';
import { isP2wpkhScript } from '../script';
import { FeeEstimator } from './fee';

export interface TxInput {
Expand Down Expand Up @@ -212,11 +213,13 @@ export class TxBuilder {
currentChangeIndex = changeIndex;
}

// Calculate network fee
currentFee = await this.calculateFee(currentFeeRate);
// Calculate the worst case of the network fee range
const acceptableFees = await this.calculateFeeRange(currentFeeRate);
currentFee = acceptableFees.max;

// If (fee = previousFee ±1), the fee is considered acceptable/expected.
isFeeExpected = [-1, 0, 1].includes(currentFee - previousFee);
// If paid enough fee, the process is considered done
const paidFee = this.summary().inputsRemaining;
isFeeExpected = paidFee >= currentFee;
if (!isLoopedOnce) {
isLoopedOnce = true;
}
Expand Down Expand Up @@ -471,20 +474,58 @@ export class TxBuilder {
}

async calculateFee(feeRate?: number): Promise<number> {
if (!feeRate && !this.feeRate) {
throw TxBuildError.withComment(ErrorCodes.INVALID_FEE_RATE, `${feeRate ?? this.feeRate}`);
}
const psbt = await this.createEstimatedPsbt();
const tx = psbt.extractTransaction(true);

const currentFeeRate = feeRate ?? this.feeRate!;
const withWitnessSize = tx.byteLength(true);
const withoutWitnessSize = tx.byteLength(false);
const witnessSize = withWitnessSize - withoutWitnessSize;
return this.calculateFeeWithSizes(withoutWitnessSize, witnessSize, feeRate);
}

async calculateFeeRange(feeRate?: number): Promise<{
min: number;
max: number;
}> {
const psbt = await this.createEstimatedPsbt();
const tx = psbt.extractTransaction(true);

const inputs = tx.ins.length;
const weightWithWitness = tx.byteLength(true);
const weightWithoutWitness = tx.byteLength(false);
let derEncodings = 0;
let witnessSizeShift = 0;
this.inputs.forEach((input, index) => {
const script = input.utxo.scriptPk;
// https://learnmeabitcoin.com/technical/keys/signature/#:~:text=DER%20Encoding-,%23,-DER%20Signature
// https://github.com/bitcoinerlab/descriptors/blob/main/src/descriptors.ts#L987-L1002
if (isP2wpkhScript(script)) {
derEncodings += 1;
const witness = tx.ins[index].witness;
if (witness[0].byteLength > 71) {
witnessSizeShift -= 1;
}
}
});

const withWitnessSize = tx.byteLength(true);
const withoutWitnessSize = tx.byteLength(false);
const witnessSize = withWitnessSize - withoutWitnessSize;

const minWitnessSize = witnessSize + witnessSizeShift;
const maxWitnessSize = witnessSize + witnessSizeShift + derEncodings;
const minFeeRate = this.calculateFeeWithSizes(withoutWitnessSize, minWitnessSize, feeRate);
const maxFeeRate = this.calculateFeeWithSizes(withoutWitnessSize, maxWitnessSize, feeRate);
return {
min: minFeeRate,
max: maxFeeRate,
};
}

calculateFeeWithSizes(withoutWitnessSize: number, witnessSize: number, feeRate?: number) {
if (!feeRate && !this.feeRate) {
throw TxBuildError.withComment(ErrorCodes.INVALID_FEE_RATE, `${feeRate ?? this.feeRate}`);
}
const currentFeeRate = feeRate ?? this.feeRate!;

const weight = weightWithoutWitness * 3 + weightWithWitness + inputs;
const weight = withoutWitnessSize * 4 + witnessSize;
const virtualSize = Math.ceil(weight / 4);
return Math.ceil(virtualSize * currentFeeRate);
}
Expand Down
39 changes: 39 additions & 0 deletions packages/btc/tests/Transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,45 @@ describe('Transaction', () => {
// const res = await service.sendBtcTransaction(tx.toHex());
// console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`);
});
it('Transfer fixed P2WPKH UTXOs, fee > feeRange', async () => {
const { builder, fee, feeRate } = await createSendUtxosBuilder({
from: accounts.charlie.p2wpkh.address,
inputs: new Array(30).fill(null).map((_, index) => {
return {
txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567',
vout: index,
value: 1000,
addressType: AddressType.P2WPKH,
address: accounts.charlie.p2wpkh.address,
scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'),
};
}),
outputs: [
{
address: accounts.charlie.p2wpkh.address,
value: 1000,
fixed: true,
},
],
source,
feeRate: 1.8,
});

// Get expected fee range (min and max)
const feeRange = await builder.calculateFeeRange(feeRate);

// Sign & finalize inputs
const psbt = builder.toPsbt();
psbt.signAllInputs(accounts.charlie.keyPair);
psbt.finalizeAllInputs();

const paidFee = psbt.getFee();
const paidFeeRate = psbt.getFeeRate();
expect(fee).toBeGreaterThanOrEqual(paidFee);
expect(fee).toBeGreaterThanOrEqual(feeRange.min);
expect(fee).toBeGreaterThanOrEqual(feeRange.max);
expect(feeRate).toBeGreaterThanOrEqual(paidFeeRate);
});

it('Transfer protected UTXO, sum(ins) = sum(outs)', async () => {
const psbt = await sendUtxos({
Expand Down

1 comment on commit 9374f41

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New snapshot version of the rgbpp-sdk packages have been released:

Name Version
@rgbpp-sdk/btc 0.0.0-snap-20240908041702
@rgbpp-sdk/ckb 0.0.0-snap-20240908041702
rgbpp 0.0.0-snap-20240908041702
@rgbpp-sdk/service 0.0.0-snap-20240908041702

Please sign in to comment.