diff --git a/packages/btc/src/transaction/build.ts b/packages/btc/src/transaction/build.ts index d5f0f63d..d8ba80f1 100644 --- a/packages/btc/src/transaction/build.ts +++ b/packages/btc/src/transaction/build.ts @@ -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 { @@ -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; } @@ -471,20 +474,58 @@ export class TxBuilder { } async calculateFee(feeRate?: number): Promise { - 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); } diff --git a/packages/btc/tests/Transaction.test.ts b/packages/btc/tests/Transaction.test.ts index a5c9ed8a..6daad57f 100644 --- a/packages/btc/tests/Transaction.test.ts +++ b/packages/btc/tests/Transaction.test.ts @@ -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({