UNPKG

@btc-stamps/tx-builder

Version:

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

494 lines (440 loc) 15.2 kB
/** * SRC-20 Specific Fee Calculator * Implements stamp-specific fee rules and minimum value requirements * Uses normalized satsPerVB for consistency with BTCStampsExplorer */ import { Buffer } from 'node:buffer'; import { FeeEstimator } from '../core/fee-estimator.ts'; import type { FeeEstimate, InputType, OutputType } from '../interfaces/fee.interface.ts'; import type { SRC20BuilderOptions } from '../interfaces/src20.interface.ts'; import { createSRC20Options } from '../interfaces/src20.interface.ts'; import { createNormalizedFeeRate, FeeNormalizer, type FeePriority, type NormalizedFeeRate, } from './fee-normalizer.ts'; export interface Src20FeeRules { preferredFeeRateSatsPerVB: number; // Higher fee rate for stamp transactions (normalized to satsPerVB) priorityMultiplier: number; // Multiplier for urgent stamp transactions maxDataOutputs: number; // Maximum data outputs per transaction } export interface Src20TransactionParams { stampValue: number; dataOutputCount: number; changeOutputType?: OutputType; hasStampInput?: boolean; isStampCreation?: boolean; isStampTransfer?: boolean; } /** * Calculator for SRC-20 stamp transaction fees with specific business rules */ export class Src20FeeCalculator extends FeeEstimator { private rules: Src20FeeRules; constructor(rules?: Partial<Src20FeeRules>, src20Options?: SRC20BuilderOptions) { super({ enableSrc20Rules: true, minFeeRate: 5, // Higher minimum for stamps }); const _options = createSRC20Options(src20Options); this.rules = { preferredFeeRateSatsPerVB: 15, // Higher than normal transactions (normalized to satsPerVB) priorityMultiplier: 2.0, // 2x multiplier for urgent maxDataOutputs: src20Options?.maxDataOutputs ?? 100, // Allow more for testing, default to 100 ...rules, }; } /** * Calculate fee for SRC-20 stamp transaction using normalized satsPerVB */ async calculateStampTransactionFee( params: Src20TransactionParams, inputs: Array<{ type: InputType; witnessScript?: Buffer }>, outputs: Array<{ type: OutputType; size?: number }>, priority: 'low' | 'medium' | 'high' | 'urgent' = 'medium', ): Promise< FeeEstimate & { src20Rules: { appliedMultiplier: number; dataOutputCount: number; recommendedFeeRateSatsPerVB: number; }; normalizedFee: NormalizedFeeRate; sizeBreakdown: { inputSize: number; outputSize: number; witnessSize: number; virtualSize: number; }; } > { // Validate stamp parameters this.validateStampParams(params); // Calculate base transaction size using normalized calculator const sizeCalculation = this.calculateTransactionSize(inputs, outputs); // Get normalized fee rate for priority level const standardFeeRate = FeeNormalizer.getStandardFeeLevel( priority as FeePriority, ); // Apply SRC-20 specific fee rate adjustments (normalized) const adjustedFeeRateSatsPerVB = this.calculateAdjustedFeeRateNormalized( params, priority, ); // Use higher of standard rate or SRC-20 adjusted rate const finalFeeRateSatsPerVB = Math.max( adjustedFeeRateSatsPerVB, standardFeeRate.satsPerVB, ); // Create normalized fee rate const normalizedFeeRate = createNormalizedFeeRate( finalFeeRateSatsPerVB, 'sat/vB', 'explorer', ); // Calculate total fee using normalized method const totalFee = FeeNormalizer.calculateFee( sizeCalculation.virtualSize, finalFeeRateSatsPerVB, ); // Get base fee estimate for backward compatibility const baseEstimate = await this.estimateFee( sizeCalculation.virtualSize, priority, ); // Calculate total effective multiplier applied const multiplier = this.calculateTotalMultiplier(params, priority); return { ...baseEstimate, feeRate: finalFeeRateSatsPerVB, // Now consistently in satsPerVB totalFee, src20Rules: { appliedMultiplier: multiplier, dataOutputCount: params.dataOutputCount, recommendedFeeRateSatsPerVB: finalFeeRateSatsPerVB, }, normalizedFee: normalizedFeeRate, sizeBreakdown: { inputSize: sizeCalculation.inputSize, outputSize: sizeCalculation.outputSize, witnessSize: sizeCalculation.witnessSize, virtualSize: sizeCalculation.virtualSize, }, }; } /** * Validate SRC-20 stamp transaction parameters */ private validateStampParams(params: Src20TransactionParams): void { // Check for conflicting transaction types if (params.isStampCreation && params.isStampTransfer) { throw new Error( 'Cannot have both isStampCreation and isStampTransfer set to true - conflicting transaction type flags', ); } // Check data output count if (params.dataOutputCount > this.rules.maxDataOutputs) { throw new Error( `Data output count ${params.dataOutputCount} exceeds maximum ${this.rules.maxDataOutputs}`, ); } // Validate stamp value is reasonable (not too large) if (params.stampValue > 1_000_000) { // 1M sats = ~$3-400 depending on price console.warn(`Large stamp value detected: ${params.stampValue} satoshis`); } } /** * Calculate adjusted fee rate for SRC-20 transactions using normalized satsPerVB */ private calculateAdjustedFeeRateNormalized( params: Src20TransactionParams, priority: 'low' | 'medium' | 'high' | 'urgent', ): number { let baseFeeRateSatsPerVB = this.rules.preferredFeeRateSatsPerVB; // Adjust for transaction type if (params.isStampCreation) { baseFeeRateSatsPerVB *= 1.2; // 20% higher for creation } else if (params.isStampTransfer) { baseFeeRateSatsPerVB *= 1.1; // 10% higher for transfers } // Adjust for data outputs (more data = higher fee) const dataOutputMultiplier = 1 + params.dataOutputCount * 0.1; // 10% per data output baseFeeRateSatsPerVB *= dataOutputMultiplier; // Apply priority multiplier const priorityMultiplier = this.getStampPriorityMultiplier( params, priority, ); baseFeeRateSatsPerVB *= priorityMultiplier; return Math.ceil(baseFeeRateSatsPerVB); } /** * Calculate adjusted fee rate for SRC-20 transactions (legacy method for backward compatibility) * @deprecated Use calculateAdjustedFeeRateNormalized instead */ // eslint-disable-next-line @typescript-eslint/no-unused-vars private __calculateAdjustedFeeRate( params: Src20TransactionParams, priority: 'low' | 'medium' | 'high' | 'urgent', ): number { return this.calculateAdjustedFeeRateNormalized(params, priority); } /** * Get priority multiplier for stamp transactions */ private getStampPriorityMultiplier( params: Src20TransactionParams, priority: 'low' | 'medium' | 'high' | 'urgent', ): number { const baseMultipliers = { low: 0.8, medium: 1.0, high: 1.5, urgent: this.rules.priorityMultiplier, }; let multiplier = baseMultipliers[priority]; // Additional multiplier for high-value stamps if (params.stampValue >= 1_000_000) { multiplier *= 1.2; } return multiplier; } /** * Calculate total effective multiplier for fee calculation */ private calculateTotalMultiplier( params: Src20TransactionParams, priority: 'low' | 'medium' | 'high' | 'urgent', ): number { let totalMultiplier = 1.0; // Transaction type multiplier if (params.isStampCreation) { totalMultiplier *= 1.2; // 20% higher for creation } else if (params.isStampTransfer) { totalMultiplier *= 1.1; // 10% higher for transfers } // Data output multiplier const dataOutputMultiplier = 1 + params.dataOutputCount * 0.1; // 10% per data output totalMultiplier *= dataOutputMultiplier; // Priority multiplier const priorityMultiplier = this.getStampPriorityMultiplier(params, priority); totalMultiplier *= priorityMultiplier; return totalMultiplier; } /** * Check if transaction qualifies for SRC-20 rules */ isStampTransaction(params: Src20TransactionParams): boolean { return ( params.hasStampInput || params.isStampCreation || params.isStampTransfer || params.dataOutputCount > 0 ); } /** * Calculate optimal change output value for stamp transactions */ calculateOptimalChange( inputValue: number, outputValue: number, estimatedFee: number, _changeType: OutputType = 'P2WPKH', ): { changeValue: number; shouldCreateChange: boolean; dustThreshold: number; } { const rawChange = inputValue - outputValue - estimatedFee; // Get dust threshold for change output const dustThreshold = this.getDustThresholds().P2WPKH; // For stamp transactions, be more conservative about change const conservativeThreshold = Math.max(dustThreshold, 1000); // At least 1000 sats const shouldCreateChange = rawChange >= conservativeThreshold; const changeValue = shouldCreateChange ? rawChange : 0; return { changeValue, shouldCreateChange, dustThreshold: conservativeThreshold, }; } /** * Get recommended fee rates for different stamp transaction types (normalized to satsPerVB) */ getRecommendedFeeRates(): { stampCreation: { low: number; medium: number; high: number; urgent: number; }; stampTransfer: { low: number; medium: number; high: number; urgent: number; }; regularWithStamp: { low: number; medium: number; high: number; urgent: number; }; } { const baseSatsPerVB = this.rules.preferredFeeRateSatsPerVB; return { stampCreation: { low: Math.ceil(baseSatsPerVB * 0.8 * 1.2), // 20% higher for creation, 80% for low priority medium: Math.ceil(baseSatsPerVB * 1.2), high: Math.ceil(baseSatsPerVB * 1.5 * 1.2), urgent: Math.ceil(baseSatsPerVB * this.rules.priorityMultiplier * 1.2), }, stampTransfer: { low: Math.ceil(baseSatsPerVB * 0.8 * 1.1), // 10% higher for transfer medium: Math.ceil(baseSatsPerVB * 1.1), high: Math.ceil(baseSatsPerVB * 1.5 * 1.1), urgent: Math.ceil(baseSatsPerVB * this.rules.priorityMultiplier * 1.1), }, regularWithStamp: { low: Math.ceil(baseSatsPerVB * 0.8), medium: baseSatsPerVB, high: Math.ceil(baseSatsPerVB * 1.5), urgent: Math.ceil(baseSatsPerVB * this.rules.priorityMultiplier), }, }; } /** * Get normalized recommended fee rates for different stamp transaction types */ getNormalizedRecommendedFeeRates(): { stampCreation: { low: NormalizedFeeRate; medium: NormalizedFeeRate; high: NormalizedFeeRate; urgent: NormalizedFeeRate; }; stampTransfer: { low: NormalizedFeeRate; medium: NormalizedFeeRate; high: NormalizedFeeRate; urgent: NormalizedFeeRate; }; regularWithStamp: { low: NormalizedFeeRate; medium: NormalizedFeeRate; high: NormalizedFeeRate; urgent: NormalizedFeeRate; }; } { const rates = this.getRecommendedFeeRates(); const createNormalized = (rate: number): NormalizedFeeRate => createNormalizedFeeRate(rate, 'sat/vB', 'explorer'); return { stampCreation: { low: createNormalized(rates.stampCreation.low), medium: createNormalized(rates.stampCreation.medium), high: createNormalized(rates.stampCreation.high), urgent: createNormalized(rates.stampCreation.urgent), }, stampTransfer: { low: createNormalized(rates.stampTransfer.low), medium: createNormalized(rates.stampTransfer.medium), high: createNormalized(rates.stampTransfer.high), urgent: createNormalized(rates.stampTransfer.urgent), }, regularWithStamp: { low: createNormalized(rates.regularWithStamp.low), medium: createNormalized(rates.regularWithStamp.medium), high: createNormalized(rates.regularWithStamp.high), urgent: createNormalized(rates.regularWithStamp.urgent), }, }; } /** * Estimate total transaction cost including stamp value */ async estimateStampTransactionCost( params: Src20TransactionParams, inputs: Array<{ type: InputType; witnessScript?: Buffer }>, outputs: Array<{ type: OutputType; size?: number }>, priority: 'low' | 'medium' | 'high' | 'urgent' = 'medium', ): Promise<{ stampValue: number; networkFee: number; totalCost: number; breakdown: { baseTransactionFee: number; stampPremium: number; dataPremium: number; priorityMultiplier: number; }; }> { const feeEstimate = await this.calculateStampTransactionFee( params, inputs, outputs, priority, ); const sizeCalculation = this.calculateTransactionSize(inputs, outputs); const baseFee = Math.ceil( sizeCalculation.virtualSize * this.rules.preferredFeeRateSatsPerVB, ); const stampPremium = params.isStampCreation ? baseFee * 0.2 : baseFee * 0.1; // 20% for creation, 10% for transfer const dataPremium = params.dataOutputCount * sizeCalculation.virtualSize * 0.1; // Data output premium const _priorityFee = feeEstimate.totalFee - baseFee - stampPremium - dataPremium; return { stampValue: params.stampValue, networkFee: feeEstimate.totalFee, totalCost: params.stampValue + feeEstimate.totalFee, breakdown: { baseTransactionFee: baseFee, stampPremium: Math.max(stampPremium, 1), // Ensure at least 1 sat dataPremium: Math.max(dataPremium, params.dataOutputCount), // At least 1 sat per data output priorityMultiplier: feeEstimate.src20Rules.appliedMultiplier, }, }; } /** * Get current SRC-20 fee rules */ getStampRules(): Src20FeeRules { return { ...this.rules }; } /** * Update SRC-20 fee rules */ updateStampRules(newRules: Partial<Src20FeeRules>): void { this.rules = { ...this.rules, ...newRules }; } } /** * Create an SRC-20 fee calculator with customizable rules for token transactions * * @param customRules - Optional partial fee rules to override defaults * @param src20Options - Optional SRC-20 specific configuration * @returns A configured Src20FeeCalculator instance * * @example Basic SRC-20 fee calculation * ```typescript * const calculator = createSrc20FeeCalculator(); * const fee = calculator.calculateTransferFee(utxos, feeRate); * ``` * * @example With custom rules * ```typescript * const calculator = createSrc20FeeCalculator({ * baseMultiplier: 1.5, * minFeeMultiplier: 2 * }); * ``` */ export function createSrc20FeeCalculator( customRules?: Partial<Src20FeeRules>, src20Options?: SRC20BuilderOptions, ): Src20FeeCalculator { return new Src20FeeCalculator(customRules, src20Options); }