UNPKG

@btc-stamps/tx-builder

Version:

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

593 lines (531 loc) 19.5 kB
/** * Enhanced Fee Estimator Implementation * Provides accurate witness size calculation and dynamic dust thresholds * Uses normalized satsPerVB for consistency with BTCStampsExplorer */ import type { DustThresholds, FeeEstimate, FeeEstimatorOptions, FeeRate, IFeeEstimator, InputType, OutputType, SizeCalculation, } from '../interfaces/fee.interface.ts'; import type { SRC20Options } from '../interfaces/src20.interface.ts'; import { createSRC20Options } from '../interfaces/src20.interface.ts'; import { ElectrumXProvider } from '../providers/electrumx-provider.ts'; import { ElectrumXFeeEstimator } from '../providers/electrumx-fee-estimator.ts'; import { createMockElectrumXFeeProvider } from '../providers/mock-electrumx-fee-provider.ts'; import type { MockElectrumXFeeProvider } from '../providers/mock-electrumx-fee-provider.ts'; import { FeeNormalizer, type FeeSource, type NormalizedFeeRate } from '../utils/fee-normalizer.ts'; import { Buffer } from 'node:buffer'; import process from 'node:process'; export class FeeEstimator implements IFeeEstimator { private options: Required<FeeEstimatorOptions>; private src20Options: Required<SRC20Options>; private electrumXFeeEstimator?: ElectrumXFeeEstimator; private mockElectrumXProvider?: MockElectrumXFeeProvider; // Accurate output sizes in bytes private static readonly OUTPUT_SIZES: Record<OutputType, number> = { P2PKH: 34, // 8 + 1 + 25 (value + script_len + script) P2WPKH: 31, // 8 + 1 + 22 (value + script_len + script) P2SH: 32, // 8 + 1 + 23 (value + script_len + script) P2WSH: 43, // 8 + 1 + 34 (value + script_len + script) P2TR: 43, // 8 + 1 + 34 (value + script_len + script) OP_RETURN: 0, // Variable, calculated separately }; // Input base sizes (excluding witness data) private static readonly INPUT_BASE_SIZES: Record<InputType, number> = { P2PKH: 148, // 36 + 1 + 107 + 4 (outpoint + script_len + script + sequence) P2WPKH: 41, // 36 + 1 + 0 + 4 (outpoint + script_len + empty script + sequence) P2SH: 91, // Variable depending on redeem script P2WSH: 41, // 36 + 1 + 0 + 4 (outpoint + script_len + empty script + sequence) P2TR: 57, // 36 + 1 + 16 + 4 (outpoint + script_len + control block + sequence) }; // Witness sizes for SegWit inputs private static readonly WITNESS_SIZES: Record<string, number> = { P2WPKH: 27, // 1 + 1 + 1 + 33 + 1 + 64 (items + sig_len + sig + pubkey_len + pubkey) P2WSH: 0, // Variable, depends on script P2TR: 16, // 1 + 1 + 64 (items + sig_len + signature) }; // Standard dust thresholds (will be adjusted dynamically) private static readonly BASE_DUST_THRESHOLDS: DustThresholds = { P2PKH: 546, P2WPKH: 294, P2SH: 540, P2WSH: 330, P2TR: 330, }; constructor(options?: FeeEstimatorOptions, src20Options?: SRC20Options) { this.options = { provider: options?.provider ?? 'mempool', fallbackFeeRate: options?.fallbackFeeRate ?? 10, minFeeRate: options?.minFeeRate ?? 1, maxFeeRate: options?.maxFeeRate ?? 1000, enableSrc20Rules: options?.enableSrc20Rules ?? true, networkType: options?.networkType ?? 'mainnet', useMockProvider: options?.useMockProvider ?? false, // Default to real provider electrumXProvider: options?.electrumXProvider ?? undefined, }; this.src20Options = createSRC20Options(src20Options); // Initialize ElectrumX provider if configured if (this.options.provider === 'electrum') { if (this.options.useMockProvider || process.env.NODE_ENV === 'test') { // Use mock for testing this.mockElectrumXProvider = createMockElectrumXFeeProvider(); } else if (this.options.electrumXProvider) { // Use injected provider this.electrumXFeeEstimator = new ElectrumXFeeEstimator( this.options.electrumXProvider, ); } else { // Create real provider for production const realProvider = new ElectrumXProvider(); this.electrumXFeeEstimator = new ElectrumXFeeEstimator(realProvider); } } } async getFeeRates(): Promise<FeeRate> { try { // Use configured provider switch (this.options.provider) { case 'electrum': if (this.electrumXFeeEstimator) { // Use real ElectrumX fee estimator const [low, medium, high, urgent] = await Promise.all([ this.electrumXFeeEstimator.getFeeEstimate('low'), this.electrumXFeeEstimator.getFeeEstimate('medium'), this.electrumXFeeEstimator.getFeeEstimate('high'), this.electrumXFeeEstimator.getFeeEstimate('urgent'), ]); return { low: low.feeRate, medium: medium.feeRate, high: high.feeRate, urgent: urgent.feeRate, }; } else if (this.mockElectrumXProvider) { // Fallback to mock (test mode) const rates = await this.mockElectrumXProvider.getFeeRates(); return this.normalizeFeeRatesFromProvider(rates, 'electrum'); } break; case 'mempool': { const mempoolRates = await this.getMempoolSpaceFeeRates(); return this.normalizeFeeRatesFromProvider(mempoolRates, 'mempool'); } case 'blockstream': { const blockstreamRates = await this.getBlockstreamFeeRates(); return this.normalizeFeeRatesFromProvider( blockstreamRates, 'explorer', ); } case 'custom': // Implementers can override this method break; } } catch (error) { console.warn(`Fee provider ${this.options.provider} failed:`, error); } // Fallback to BTCStampsExplorer standard rates (already normalized) const standardRates = FeeNormalizer.getAllStandardFeeLevels(); return { low: standardRates.low.satsPerVB, medium: standardRates.medium.satsPerVB, high: standardRates.high.satsPerVB, urgent: standardRates.urgent.satsPerVB, }; } async estimateFee( size: number, priority: 'low' | 'medium' | 'high' | 'urgent', ): Promise<FeeEstimate> { const feeRates = await this.getFeeRates(); const feeRate = feeRates[priority] || feeRates.medium; const totalFee = Math.ceil(size * feeRate); const timeEstimates = { low: '1-2 hours', medium: '30-60 minutes', high: '10-20 minutes', urgent: '5-10 minutes', }; const blockEstimates = { low: 6, medium: 3, high: 1, urgent: 1, }; const confidenceLevel = { low: 0.7, medium: 0.85, high: 0.95, urgent: 0.99, }; return { feeRate, totalFee, confidence: confidenceLevel[priority], blocks: blockEstimates[priority], confirmationTime: timeEstimates[priority], priority, }; } calculateTransactionSize( inputs: Array<{ type: InputType; witnessScript?: Buffer }>, outputs: Array<{ type: OutputType; size?: number }>, ): SizeCalculation { // Use the normalized fee calculator for consistent virtual size calculation const calculation = FeeNormalizer.calculateVirtualSizeFromParams( inputs, outputs, ); return { inputSize: calculation.inputSizes.reduce((sum, size) => sum + size, 0), outputSize: calculation.outputSizes.reduce((sum, size) => sum + size, 0), witnessSize: calculation.witnessSizes.reduce( (sum, size) => sum + size, 0, ), virtualSize: calculation.virtualSize, }; } getOutputSize(type: OutputType, scriptSize?: number): number { if (type === 'OP_RETURN' && scriptSize !== undefined) { return 8 + 1 + scriptSize; // value + script_len + script } return FeeEstimator.OUTPUT_SIZES[type]; } getInputSize(type: InputType, witnessScript?: Buffer): SizeCalculation { const baseSize = FeeEstimator.INPUT_BASE_SIZES[type]; let witnessSize: number = 0; switch (type) { case 'P2WPKH': witnessSize = FeeEstimator.WITNESS_SIZES.P2WPKH ?? 0; break; case 'P2WSH': if (witnessScript) { // Witness stack: script + other items witnessSize = 1 + witnessScript.length + 64; // items count + script + signature } else { witnessSize = FeeEstimator.WITNESS_SIZES.P2WSH ?? 0; } break; case 'P2TR': witnessSize = FeeEstimator.WITNESS_SIZES.P2TR ?? 0; break; case 'P2SH': // P2SH size depends on the redeem script if (witnessScript) { return { inputSize: baseSize + witnessScript.length, outputSize: 0, witnessSize: 0, // P2SH doesn't use witness data virtualSize: baseSize + witnessScript.length, }; } witnessSize = 0; // P2SH doesn't use witness data break; case 'P2PKH': witnessSize = 0; // P2PKH doesn't use witness data break; } return { inputSize: baseSize, outputSize: 0, witnessSize, virtualSize: witnessSize > 0 ? baseSize + Math.ceil(witnessSize / 4) : baseSize, }; } getDustThresholds(feeRate?: number): DustThresholds { const rate = feeRate || 3; // Default relay fee rate // Dynamic dust calculation: (input_size + output_size) * fee_rate const thresholds: DustThresholds = { P2PKH: 0, P2WPKH: 0, P2SH: 0, P2WSH: 0, P2TR: 0, }; // Calculate dust for each output type Object.keys(thresholds).forEach((outputType) => { const type = outputType as keyof DustThresholds; const outputSize = FeeEstimator.OUTPUT_SIZES[type as OutputType]; // Use P2WPKH input for spending the output (most efficient) const spendInputSize = this.getInputSize('P2WPKH').virtualSize; const dustValue = (outputSize + spendInputSize) * rate; thresholds[type] = Math.max( dustValue, FeeEstimator.BASE_DUST_THRESHOLDS[type], ); }); return thresholds; } async calculateCPFP( parentTxid: string, parentFee: number, childSize: number, targetFeeRate: number, ): Promise<number> { // Try to get parent transaction if we have a real provider if (this.electrumXFeeEstimator?.provider) { try { // Fetch parent transaction const parentTx = await this.electrumXFeeEstimator.provider.getTransaction(parentTxid); if (parentTx) { const parentSize = parentTx.vsize || parentTx.size || 250; const actualParentFee = parentTx.fee || parentFee; // Standard CPFP calculation const combinedSize = parentSize + childSize; const targetTotalFee = combinedSize * targetFeeRate; const requiredChildFee = Math.max( targetTotalFee - actualParentFee, childSize, // Minimum 1 sat/vbyte ); return requiredChildFee; } } catch (error) { console.warn( `Failed to fetch parent transaction ${parentTxid}, using estimated size:`, error, ); } } // Fallback to estimated calculation (for mock provider or if real fetch fails) const estimatedParentSize = 250; // Average transaction size const totalSize = estimatedParentSize + childSize; const totalFeeNeeded = totalSize * targetFeeRate; const childFeeNeeded = totalFeeNeeded - parentFee; return Math.max(childFeeNeeded, childSize); // Minimum 1 sat/vbyte } calculateRBF(originalFee: number, minRelayFee: number = 1): number { // RBF requires absolute fee increase of at least 1 sat/vbyte of replacement tx size // For simplicity, add 25% to original fee or minimum relay fee, whichever is higher const minimumIncrease = Math.max(originalFee * 0.25, minRelayFee); return originalFee + minimumIncrease; } /** * Get fee estimation for specific transaction parameters */ async getOptimalFee(params: { inputs: Array<{ type: InputType; witnessScript?: Buffer }>; outputs: Array<{ type: OutputType; size?: number }>; priority?: 'low' | 'medium' | 'high' | 'urgent'; }): Promise<FeeEstimate & { sizeBreakdown: SizeCalculation }> { const { inputs, outputs, priority = 'medium' } = params; const sizeBreakdown = this.calculateTransactionSize(inputs, outputs); const feeEstimate = await this.estimateFee( sizeBreakdown.virtualSize, priority, ); return { ...feeEstimate, sizeBreakdown, }; } /** * Validate if output value is above dust threshold */ isAboveDustThreshold( value: number, outputType: OutputType, feeRate?: number, ): boolean { const thresholds = this.getDustThresholds(feeRate); const threshold = thresholds[outputType as keyof DustThresholds]; return value >= threshold; } /** * Normalize fee rates from external providers to ensure consistency */ private normalizeFeeRatesFromProvider( rates: FeeRate, source: FeeSource, ): FeeRate { return { low: FeeNormalizer.normalizeFeeRate(rates.low, source).satsPerVB, medium: FeeNormalizer.normalizeFeeRate(rates.medium, source).satsPerVB, high: FeeNormalizer.normalizeFeeRate(rates.high, source).satsPerVB, urgent: rates.urgent ? FeeNormalizer.normalizeFeeRate(rates.urgent, source).satsPerVB : undefined, }; } /** * Get normalized fee rates for all priority levels */ async getNormalizedFeeRates(): Promise<{ low: NormalizedFeeRate; medium: NormalizedFeeRate; high: NormalizedFeeRate; urgent: NormalizedFeeRate; }> { try { switch (this.options.provider) { case 'electrum': if (this.electrumXFeeEstimator) { // Use real ElectrumX fee estimator const [low, medium, high, urgent] = await Promise.all([ this.electrumXFeeEstimator.getFeeEstimate('low'), this.electrumXFeeEstimator.getFeeEstimate('medium'), this.electrumXFeeEstimator.getFeeEstimate('high'), this.electrumXFeeEstimator.getFeeEstimate('urgent'), ]); return { low: FeeNormalizer.normalizeFeeRate(low.feeRate, 'electrum'), medium: FeeNormalizer.normalizeFeeRate(medium.feeRate, 'electrum'), high: FeeNormalizer.normalizeFeeRate(high.feeRate, 'electrum'), urgent: FeeNormalizer.normalizeFeeRate(urgent.feeRate, 'electrum'), }; } else if (this.mockElectrumXProvider) { // Fallback to mock (test mode) const rates = await this.mockElectrumXProvider.getFeeRates(); return { low: FeeNormalizer.normalizeFeeRate(rates.low, 'electrum'), medium: FeeNormalizer.normalizeFeeRate(rates.medium, 'electrum'), high: FeeNormalizer.normalizeFeeRate(rates.high, 'electrum'), urgent: FeeNormalizer.normalizeFeeRate( rates.urgent || rates.high * 1.5, 'electrum', ), }; } break; case 'mempool': { const mempoolRates = await this.getMempoolSpaceFeeRates(); return { low: FeeNormalizer.normalizeFeeRate(mempoolRates.low, 'mempool'), medium: FeeNormalizer.normalizeFeeRate( mempoolRates.medium, 'mempool', ), high: FeeNormalizer.normalizeFeeRate(mempoolRates.high, 'mempool'), urgent: FeeNormalizer.normalizeFeeRate( mempoolRates.urgent || mempoolRates.high * 1.5, 'mempool', ), }; } case 'blockstream': { const blockstreamRates = await this.getBlockstreamFeeRates(); return { low: FeeNormalizer.normalizeFeeRate( blockstreamRates.low, 'explorer', ), medium: FeeNormalizer.normalizeFeeRate( blockstreamRates.medium, 'explorer', ), high: FeeNormalizer.normalizeFeeRate( blockstreamRates.high, 'explorer', ), urgent: FeeNormalizer.normalizeFeeRate( blockstreamRates.urgent || blockstreamRates.high * 1.5, 'explorer', ), }; } } } catch (error) { console.warn(`Fee provider ${this.options.provider} failed:`, error); } // Fallback to BTCStampsExplorer standard rates return FeeNormalizer.getAllStandardFeeLevels(); } /** * Get fee rates from mempool.space API */ private async getMempoolSpaceFeeRates(): Promise<FeeRate> { const response = await fetch( 'https://mempool.space/api/v1/fees/recommended', ); if (!response.ok) { throw new Error(`Mempool.space API error: ${response.status}`); } const data = await response.json(); return { low: data.economyFee || 5, medium: data.hourFee || 15, high: data.halfHourFee || 30, urgent: data.fastestFee || 50, }; } /** * Get fee rates from Blockstream API */ private async getBlockstreamFeeRates(): Promise<FeeRate> { const response = await fetch('https://blockstream.info/api/fee-estimates'); if (!response.ok) { throw new Error(`Blockstream API error: ${response.status}`); } const data = await response.json(); // Map block targets to priority levels return { urgent: data['1'] || 50, // Next block high: data['3'] || 30, // 3 blocks medium: data['6'] || 15, // 6 blocks low: data['25'] || 5, // 25 blocks }; } /** * Set custom fee provider */ setProvider(provider: NonNullable<FeeEstimatorOptions['provider']>): void { this.options.provider = provider; // Initialize ElectrumX if switching to electrum if (provider === 'electrum' && !this.electrumXFeeEstimator && !this.mockElectrumXProvider) { if (this.options.useMockProvider || process.env.NODE_ENV === 'test') { this.mockElectrumXProvider = createMockElectrumXFeeProvider(); } else { const realProvider = new ElectrumXProvider(); this.electrumXFeeEstimator = new ElectrumXFeeEstimator(realProvider); } } } /** * Get current provider configuration */ getProviderInfo(): { provider: string; electrumXConnected: boolean; fallbackFeeRate: number; useMockProvider: boolean; } { return { provider: this.options.provider, electrumXConnected: !!(this.electrumXFeeEstimator || this.mockElectrumXProvider), fallbackFeeRate: this.options.fallbackFeeRate, useMockProvider: !!this.mockElectrumXProvider, }; } /** * Test connection to current fee provider */ async testProvider(): Promise<{ success: boolean; provider: string; latency?: number; error?: string; }> { const startTime = Date.now(); try { await this.getFeeRates(); const latency = Date.now() - startTime; return { success: true, provider: this.options.provider, latency, }; } catch (error) { return { success: false, provider: this.options.provider, error: error instanceof Error ? error.message : String(error), }; } } }