UNPKG

@btc-stamps/tx-builder

Version:

Transaction builder for Bitcoin Stamps and SRC-20 tokens with advanced UTXO selection

568 lines (487 loc) 16.7 kB
/** * RBF (Replace-By-Fee) Builder * Implementation for creating replacement transactions */ import { Buffer } from 'node:buffer'; import * as bitcoin from 'bitcoinjs-lib'; import type { Network, Psbt, Transaction } from 'bitcoinjs-lib'; import type { UTXO } from '../interfaces/provider.interface.ts'; import type { IRBFBuilder, RBFConfig, RBFTransaction } from '../interfaces/rbf-cpfp.interface.ts'; import { InsufficientFeeBumpError } from '../interfaces/rbf-cpfp.interface.ts'; /** * RBF Builder Implementation */ export class RBFBuilder implements IRBFBuilder { private network: Network; private defaultMinFeeIncrease = 1; // sat/vB minimum increase constructor(network: Network = bitcoin.networks.bitcoin) { this.network = network; } /** * Create RBF replacement transaction */ createReplacement( originalTx: Transaction, config: RBFConfig, availableUtxos: UTXO[], ): Promise<RBFTransaction> { // Validate original transaction signals RBF if (!this.signalsRBF(originalTx)) { throw new Error( 'Original transaction does not signal RBF (sequence >= 0xfffffffe)', ); } // Calculate original transaction metrics const originalSize = originalTx.virtualSize(); let originalFee: number; try { originalFee = this.calculateTransactionFee(originalTx, availableUtxos); // If we can't find the original UTXOs (common case), estimate if (originalFee <= 0) { throw new Error('Cannot calculate fee from available UTXOs'); } } catch { // If we can't calculate the exact fee, estimate based on size and config const estimatedFeeRate = config.originalFeeRate || 10; // Use provided rate or default 10 sat/vB originalFee = Math.max(1, Math.ceil(originalSize * estimatedFeeRate)); } const originalFeeRate = originalFee / originalSize; // Determine target fee rate const minRequiredFeeRate = originalFeeRate + (config.minFeeRateIncrease || this.defaultMinFeeIncrease); let targetFeeRate = config.targetFeeRate || minRequiredFeeRate; // Apply maximum fee rate cap if specified if (config.maxFeeRate && targetFeeRate > config.maxFeeRate) { targetFeeRate = config.maxFeeRate; } if (targetFeeRate < minRequiredFeeRate) { throw new InsufficientFeeBumpError( config.originalTxid, config.minFeeRateIncrease || this.defaultMinFeeIncrease, targetFeeRate - originalFeeRate, ); } // Create replacement PSBT const psbt = new bitcoin.Psbt({ network: this.network }); // Set version and locktime to match original if not replacing all inputs if (!config.replaceAllInputs) { psbt.setVersion(originalTx.version); if (originalTx.locktime > 0) { psbt.setLocktime(originalTx.locktime); } } // Add inputs (with RBF signaling) const inputUtxos: UTXO[] = []; let addedInputs = false; const addedUtxos: UTXO[] = []; if (config.replaceAllInputs) { // Replace all inputs - use available UTXOs const selectedUtxos = this.selectUtxosForReplacement( availableUtxos, this.calculateTotalOutputValue(originalTx), targetFeeRate, ); for (const utxo of selectedUtxos) { this.addInputToPsbt(psbt, utxo); inputUtxos.push(utxo); } addedInputs = true; addedUtxos.push(...selectedUtxos); } else { // Keep original inputs, potentially add more for fee const originalInputs = this.extractOriginalInputs( originalTx, availableUtxos, ); for (const utxo of originalInputs) { this.addInputToPsbt(psbt, utxo, true); // Enable RBF inputUtxos.push(utxo); } // Check if additional inputs needed for fee bump const currentValue = inputUtxos.reduce( (sum, utxo) => sum + utxo.value, 0, ); const outputValue = this.calculateTotalOutputValue(originalTx); const estimatedSize = this.estimateReplacementSize(originalTx, 0); const requiredFee = Math.ceil(estimatedSize * targetFeeRate); if (currentValue < outputValue + requiredFee) { const additionalValueNeeded = outputValue + requiredFee - currentValue; const additionalUtxos = this.selectAdditionalUtxos( config.additionalUtxos || [], additionalValueNeeded, ); if (additionalUtxos.length > 0) { for (const utxo of additionalUtxos) { this.addInputToPsbt(psbt, utxo, true); // Enable RBF inputUtxos.push(utxo); } addedInputs = true; addedUtxos.push(...additionalUtxos); } } } // Add outputs (copy from original, adjust for fee) const totalInputValue = inputUtxos.reduce( (sum, utxo) => sum + utxo.value, 0, ); this.addOutputsToPsbt( psbt, originalTx, totalInputValue, targetFeeRate, config.changeAddress, ); // Calculate final metrics const newSize = this.estimateTransactionSize(psbt); const newFee = totalInputValue - this.getTotalOutputValue(psbt); const newFeeRate = newFee / newSize; // Validate replacement const validation = this.validateRBF(originalTx, psbt); const result: RBFTransaction = { psbt, originalTxid: config.originalTxid, originalFee, newFee, feeIncrease: newFee - originalFee, originalFeeRate, newFeeRate, addedInputs, addedUtxos, valid: validation.valid, messages: [...validation.errors, ...validation.warnings], }; return Promise.resolve(result); } /** * Calculate minimum fee for RBF */ calculateMinimumRBFFee(originalTx: Transaction, newSize?: number): number { const originalFee = this.estimateOriginalFee(originalTx); const size = newSize || originalTx.virtualSize(); // BIP 125: replacement must pay higher absolute fee and fee rate const minFeeIncrease = Math.max( 1, // Minimum 1 satoshi increase Math.ceil(size * this.defaultMinFeeIncrease), // Minimum rate increase ); return originalFee + minFeeIncrease; } /** * Validate RBF transaction */ validateRBF( originalTx: Transaction, replacementPsbt: Psbt, ): { valid: boolean; errors: string[]; warnings: string[]; } { const errors: string[] = []; const warnings: string[] = []; try { // Check RBF signaling in original transaction if (!this.signalsRBF(originalTx)) { errors.push('Original transaction does not signal RBF'); } // Check RBF signaling in replacement if (!this.signalsRBF(replacementPsbt)) { warnings.push( 'Replacement transaction does not signal RBF for future replacements', ); } // Validate fee increase (BIP 125 rule 3) const originalFee = this.estimateOriginalFee(originalTx); const replacementFee = this.calculatePsbtFee(replacementPsbt); if (replacementFee <= originalFee) { errors.push( `Replacement fee (${replacementFee}) must be higher than original fee (${originalFee})`, ); } // Validate fee rate increase (BIP 125 rule 4) const originalFeeRate = originalFee / originalTx.virtualSize(); const replacementSize = this.estimateTransactionSize(replacementPsbt); const replacementFeeRate = replacementFee / replacementSize; if (replacementFeeRate <= originalFeeRate) { errors.push( `Replacement fee rate (${replacementFeeRate.toFixed(2)}) must be higher than original (${ originalFeeRate.toFixed(2) })`, ); } // Check for additional unconfirmed dependencies (BIP 125 rule 2) // This would require mempool information, so we add a warning warnings.push('Verify replacement does not add unconfirmed dependencies'); } catch (error) { errors.push( `Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`, ); } return { valid: errors.length === 0, errors, warnings, }; } /** * Check if transaction signals RBF */ signalsRBF(tx: Transaction | Psbt): boolean { if (tx instanceof bitcoin.Psbt) { // Check PSBT inputs for (let i = 0; i < tx.inputCount; i++) { const sequence = tx.txInputs[i]?.sequence ?? 0xffffffff; if (sequence < 0xfffffffe) { return true; } } return false; } else { // Check transaction inputs for (const input of tx.ins) { if (input.sequence < 0xfffffffe) { return true; } } return false; } } /** * Enable RBF signaling in PSBT */ enableRBF(psbt: Psbt): void { for (let i = 0; i < psbt.inputCount; i++) { psbt.setInputSequence(i, 0xfffffffd); } } // Private helper methods private calculateTransactionFee(tx: Transaction, utxos: UTXO[]): number { // Calculate input value let inputValue = 0; for (const input of tx.ins) { const utxo = utxos.find( (u) => u.txid === Buffer.from(input.hash).reverse().toString('hex') && u.vout === input.index, ); if (utxo) { inputValue += utxo.value; } } // Calculate output value const outputValue = tx.outs.reduce((sum, out) => sum + out.value, 0); return inputValue - outputValue; } private calculateTotalOutputValue(tx: Transaction): number { return tx.outs.reduce((sum, out) => sum + out.value, 0); } private extractOriginalInputs( tx: Transaction, availableUtxos: UTXO[], ): UTXO[] { const inputs: UTXO[] = []; for (const input of tx.ins) { const txid = Buffer.from(input.hash).reverse().toString('hex'); const vout = input.index; const utxo = availableUtxos.find((u) => u.txid === txid && u.vout === vout); if (utxo) { inputs.push(utxo); } } return inputs; } private selectUtxosForReplacement( availableUtxos: UTXO[], targetValue: number, feeRate: number, ): UTXO[] { // Simple selection algorithm - can be enhanced with more sophisticated logic const sorted = [...availableUtxos].sort((a, b) => b.value - a.value); const selected: UTXO[] = []; let totalValue = 0; for (const utxo of sorted) { selected.push(utxo); totalValue += utxo.value; // Estimate fee for current selection const estimatedSize = this.estimateInputOutputSize(selected.length, 2); // Assume 2 outputs const estimatedFee = Math.ceil(estimatedSize * feeRate); if (totalValue >= targetValue + estimatedFee) { break; } } return selected; } private selectAdditionalUtxos( availableUtxos: UTXO[], additionalValue: number, ): UTXO[] { const sorted = [...availableUtxos].sort((a, b) => a.value - b.value); // Prefer smaller UTXOs first const selected: UTXO[] = []; let totalValue = 0; for (const utxo of sorted) { selected.push(utxo); totalValue += utxo.value; if (totalValue >= additionalValue) { break; } } return selected; } private addInputToPsbt(psbt: Psbt, utxo: UTXO, enableRbf = true): void { const inputData: any = { hash: Buffer.from(utxo.txid, 'hex').reverse(), // Convert hex string to reversed buffer index: utxo.vout, }; // Enable RBF signaling if (enableRbf) { inputData.sequence = 0xfffffffd; } // Add witness UTXO (required for SegWit) inputData.witnessUtxo = { script: Buffer.from(utxo.scriptPubKey, 'hex'), value: utxo.value, }; psbt.addInput(inputData); } private addOutputsToPsbt( psbt: Psbt, originalTx: Transaction, totalInputValue: number, targetFeeRate: number, changeAddress?: string, ): void { const estimatedSize = this.estimateReplacementSize( originalTx, psbt.inputCount - originalTx.ins.length, ); const targetFee = Math.ceil(estimatedSize * targetFeeRate); let remainingValue = totalInputValue - targetFee; // Add original outputs (except change if we're modifying it) for (let i = 0; i < originalTx.outs.length; i++) { const output = originalTx.outs[i]!; // Check if this might be a change output (last output, under certain threshold) const mightBeChange = i === originalTx.outs.length - 1 && changeAddress; if (mightBeChange && output.value > remainingValue) { // Adjust change output if (remainingValue > 546) { // Dust threshold psbt.addOutput({ address: changeAddress, value: remainingValue, }); } remainingValue = 0; } else { // Copy original output try { const address = bitcoin.address.fromOutputScript( output.script, this.network, ); psbt.addOutput({ address, value: output.value, }); remainingValue -= output.value; } catch { // If address extraction fails, use script directly psbt.addOutput({ script: output.script, value: output.value, }); remainingValue -= output.value; } } } // Add new change output if needed if ( remainingValue > 546 && changeAddress && !this.hasChangeOutput(originalTx) ) { psbt.addOutput({ address: changeAddress, value: remainingValue, }); } } private estimateReplacementSize( originalTx: Transaction, additionalInputs: number, ): number { // Rough estimation based on original transaction const baseSize = originalTx.virtualSize(); const additionalInputSize = additionalInputs * 68; // Approximate SegWit input size return baseSize + additionalInputSize; } private estimateInputOutputSize( numInputs: number, numOutputs: number, ): number { // Rough estimation for SegWit transactions const baseSize = 10; // version, locktime, input/output counts const inputSize = numInputs * 68; // Approximate SegWit input size const outputSize = numOutputs * 31; // Approximate output size return baseSize + inputSize + outputSize; } private estimateTransactionSize(psbt: Psbt): number { // Rough estimation - in production, use more accurate calculation const inputCount = psbt.inputCount; const outputCount = psbt.txOutputs.length; return this.estimateInputOutputSize(inputCount, outputCount); } private getTotalOutputValue(psbt: Psbt): number { let total = 0; for (let i = 0; i < psbt.txOutputs.length; i++) { total += psbt.txOutputs[i]?.value ?? 0; } return total; } private calculatePsbtFee(psbt: Psbt): number { let inputValue = 0; let outputValue = 0; // Calculate total input value from witness UTXOs for (let i = 0; i < psbt.inputCount; i++) { const input = psbt.data.inputs[i]; if (input?.witnessUtxo) { inputValue += input.witnessUtxo.value; } else if (input?.nonWitnessUtxo) { // For non-SegWit inputs, we need to extract the value from the full transaction // This is more complex and requires parsing the transaction throw new Error( 'Non-witness UTXO calculation not implemented - use witness UTXOs for fee calculation', ); } else { throw new Error( `Missing UTXO data for input ${i} - cannot calculate fee`, ); } } // Calculate total output value for (let i = 0; i < psbt.txOutputs.length; i++) { const output = psbt.txOutputs[i]; if (output) { outputValue += output.value; } } // Fee = inputs - outputs const fee = inputValue - outputValue; if (fee < 0) { throw new Error( `Invalid fee calculation: ${fee} (inputs: ${inputValue}, outputs: ${outputValue})`, ); } return fee; } private estimateOriginalFee(tx: Transaction): number { // This is a placeholder - in practice, you'd need UTXO information // For now, estimate based on size and average fee rate const estimatedFeeRate = 10; // sat/vB return tx.virtualSize() * estimatedFeeRate; } private hasChangeOutput(tx: Transaction): boolean { // Simple heuristic - check if last output is under a threshold if (tx.outs.length === 0) return false; const lastOutput = tx.outs[tx.outs.length - 1]!; return lastOutput.value < 10000; // 0.0001 BTC threshold } }