UNPKG

@btc-stamps/tx-builder

Version:

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

624 lines (540 loc) 20.4 kB
/** * Bitcoin Stamp Builder * * Implements Bitcoin Stamp transaction construction with proper UTXO management, * P2WSH encoding, and Counterparty protocol integration. Provides a high-level * builder interface similar to SRC20TokenBuilder for consistent developer experience. */ import { Buffer } from 'node:buffer'; import * as bitcoin from 'bitcoinjs-lib'; import { TransactionBuilder } from '../core/transaction-builder.ts'; import { type BitcoinStampEncodingOptions, BitcoinStampsEncoder, } from '../encoders/bitcoin-stamps-encoder.ts'; import type { UTXO } from '../interfaces/provider.interface.ts'; import type { SelectionOptions, SelectorFactory } from '../interfaces/selector.interface.ts'; import { type EnhancedSelectionResult, isSelectionSuccess as _isSelectionSuccess, SelectionFailureReason, } from '../interfaces/selector-result.interface.ts'; import type { TransactionOutput } from '../interfaces/transaction.interface.ts'; import { ConsoleLogger, Logger } from '../utils/logger.ts'; import { createAdvancedFeeCalculator } from '../calculators/advanced-fee-calculator.ts'; import type { AdvancedFeeCalculator } from '../calculators/advanced-fee-calculator.ts'; /** * Bitcoin Stamp Transaction Builder Configuration */ export interface BitcoinStampBuilderConfig { network: bitcoin.networks.Network; feeRate: number; dustThreshold: number; maxInputs: number; enableRBF: boolean; enableCPFP?: boolean; utxoProvider: any; selectorFactory: SelectorFactory; assetValidationService?: any; // IAssetValidationService for CPID validation/generation logger?: Logger; } /** * Bitcoin Stamp Build Data */ export interface BitcoinStampBuildData { data: Buffer; fromAddress: string; encoding?: 'gzip' | 'brotli' | 'base64'; cpid?: string; // CPID (Counterparty ID) for the stamp supply?: number; isLocked?: boolean; filename?: string; title?: string; description?: string; creator?: string; } /** * Simple issuance data (no image/file data) */ export interface BitcoinStampIssuanceData { sourceAddress: string; cpid: string; quantity: number; divisible?: boolean; lock?: boolean; description?: string; imageData?: string; // Optional image data for testing } /** * High-level builder for creating Bitcoin Stamp transactions * * @remarks * BitcoinStampBuilder simplifies the creation of Bitcoin Stamp transactions by handling: * - Image/file data encoding and compression * - Multi-signature output creation for data storage * - UTXO selection with protection for special assets * - Fee calculation optimized for stamp transactions * - Counterparty protocol integration * * Features: * - Automatic data compression (gzip/brotli) * - Base64 encoding for binary data * - CPID support for Counterparty assets * - Built-in UTXO protection (Ordinals, Stamps, etc.) * - Configurable fee rates and algorithms * * @example * ```typescript * const builder = new BitcoinStampBuilder(network, selectorFactory); * const result = await builder.buildStampTransaction(utxos, { * stampData: { * imageData: imageBuffer, * filename: 'art.png' * }, * fromAddress: 'bc1q...', * feeRate: 20 * }); * ``` */ export class BitcoinStampBuilder extends TransactionBuilder { public readonly network: bitcoin.networks.Network; public readonly dustThreshold: number; public readonly feeRate: number; public readonly maxInputs: number; public readonly enableRBF: boolean; public readonly enableCPFP: boolean; private readonly encoder: BitcoinStampsEncoder; private readonly selectorFactory: SelectorFactory; private readonly feeCalculator: AdvancedFeeCalculator; private readonly assetValidationService?: any; // IAssetValidationService private readonly utxoProvider: any; // IUTXOProvider private readonly logger: Logger; constructor(config: BitcoinStampBuilderConfig) { super({ network: config.network, defaultRbf: config.enableRBF, }); this.network = config.network; this.dustThreshold = config.dustThreshold; this.feeRate = config.feeRate; this.maxInputs = config.maxInputs; this.enableRBF = config.enableRBF; this.enableCPFP = config.enableCPFP || false; this.encoder = new BitcoinStampsEncoder(); this.selectorFactory = config.selectorFactory; this.feeCalculator = createAdvancedFeeCalculator(); this.assetValidationService = config.assetValidationService; this.utxoProvider = config.utxoProvider; // Store utxoProvider this.logger = config.logger || new ConsoleLogger(); this.logger.debug?.('BitcoinStampBuilder initialized', { network: config.network.bech32, feeRate: this.feeRate, dustThreshold: this.dustThreshold, maxInputs: this.maxInputs, enableRBF: this.enableRBF, enableCPFP: this.enableCPFP, }); } /** * Build a Bitcoin Stamp transaction */ async buildStampTransaction(buildData: BitcoinStampBuildData): Promise<bitcoin.Transaction> { this.logger.debug?.('Building Bitcoin Stamp transaction', { dataSize: buildData.data.length, fromAddress: buildData.fromAddress, encoding: buildData.encoding, }); try { // Validate build data this.validateBuildData(buildData); // Validate description length for OP_RETURN constraints this.validateDescriptionLength(buildData.description || ''); // Get funding UTXOs const utxos = await this.getUTXOs(buildData.fromAddress); // Validate or generate CPID let cpid = buildData.cpid; if (this.assetValidationService) { if (cpid) { // Validate the provided CPID using validateAndPrepareAssetName // This will throw if invalid or unavailable cpid = await this.assetValidationService.validateAndPrepareAssetName(cpid); this.logger.debug?.(`CPID validated: ${cpid}`); } else { // Generate a new CPID if not provided cpid = await this.assetValidationService.generateAvailableAssetName(); this.logger.debug?.(`Generated new CPID: ${cpid}`); } } else if (!cpid) { // If no validation service and no CPID provided, generate a simple one cpid = `A${Date.now()}${Math.floor(Math.random() * 1000000)}`; this.logger.debug?.(`Generated fallback CPID: ${cpid}`); } // Create stamp data for encoder const stampData = { imageData: buildData.data, filename: buildData.filename, title: buildData.title, description: buildData.description, creator: buildData.creator, }; // Create encoding options // The encoder needs UTXOs for RC4 key generation, so we pass the first UTXO const encodingOptions: BitcoinStampEncodingOptions = { cpid: cpid, // Pass validated/generated CPID to the encoder supply: buildData.supply || 1, isLocked: buildData.isLocked !== false, // Default to true utxos: utxos.slice(0, 1).map((u) => ({ // Pass first UTXO for RC4 key txid: u.txid, vout: u.vout, value: u.value, })), }; // Encode the stamp data const encodingResult = await this.encoder.encode(stampData, encodingOptions); if (!encodingResult) { throw new Error('Failed to encode Bitcoin Stamp data'); } // CRITICAL: Use ALL outputs from encoder (OP_RETURN + P2WSH) // The encoder returns both the Counterparty OP_RETURN and the fake P2WSH outputs if (!encodingResult.outputs || encodingResult.outputs.length === 0) { throw new Error('Failed to create stamp outputs'); } // Use the encoder's outputs directly - they are already properly formatted const outputs: TransactionOutput[] = encodingResult.outputs.map((output) => ({ script: output.script, value: output.value, })); // Calculate total output value const totalOutputValue = outputs.reduce((sum, output) => sum + output.value, 0); // Estimate fee const estimatedSize = this.estimateTransactionSize(utxos.length, outputs.length + 1); // +1 for change const estimatedFee = Math.ceil(estimatedSize * this.feeRate); // Simple fee calculation: size * rate const targetValue = totalOutputValue + estimatedFee; // Select UTXOs const selectionResult = this.selectUTXOs( utxos, targetValue, this.feeRate, this.dustThreshold, ); if (!selectionResult.success) { throw new Error(`UTXO selection failed: ${selectionResult.message}`); } // Add change output if necessary // IMPORTANT: Change goes to an address, not a script if (selectionResult.change > this.dustThreshold) { const changeScript = bitcoin.address.toOutputScript(buildData.fromAddress, this.network); outputs.push({ script: changeScript, value: selectionResult.change, }); } this.logger.debug?.('Building transaction with selected UTXOs', { inputCount: selectionResult.inputs.length, outputCount: outputs.length, totalValue: selectionResult.totalValue, fee: selectionResult.fee, change: selectionResult.change, }); // Build the transaction const transaction = new bitcoin.Transaction(); transaction.version = 2; // Add inputs selectionResult.inputs.forEach((input: UTXO) => { transaction.addInput( Buffer.from(input.txid, 'hex').reverse(), input.vout, ); }); // Add outputs - all should have scripts at this point outputs.forEach((output) => { if (output.script) { transaction.addOutput(output.script, output.value); } else { throw new Error('Output missing script'); } }); this.logger.debug?.('Bitcoin Stamp transaction built successfully', { txid: transaction.getId(), size: transaction.virtualSize(), fee: selectionResult.fee, }); return transaction; } catch (error) { this.logger.error?.( 'Failed to build Bitcoin Stamp transaction:', error as Record<string, unknown>, ); throw error; } } /** * Build a simple issuance transaction (for testing or simple asset creation) */ async buildIssuance(issuanceData: BitcoinStampIssuanceData): Promise<bitcoin.Transaction> { this.logger.debug?.( 'Building Bitcoin Stamp issuance transaction', issuanceData as unknown as Record<string, unknown>, ); // Validate description length for OP_RETURN constraints this.validateDescriptionLength(issuanceData.description || ''); try { // Get funding UTXOs const utxos = await this.getUTXOs(issuanceData.sourceAddress); // Convert to CounterpartyEncoder format const encoder = new (await import('../encoders/counterparty-encoder.ts')) .CounterpartyEncoder(); const assetIdNum = BigInt(issuanceData.cpid.slice(1)); // Remove 'A' prefix // Encode the issuance message const encodingResult = encoder.encodeIssuance({ assetId: assetIdNum, quantity: issuanceData.quantity, divisible: issuanceData.divisible || false, lock: issuanceData.lock || false, description: issuanceData.description || '', }); if (!encodingResult) { throw new Error('Failed to encode issuance data'); } // Create OP_RETURN output const opReturnData = Buffer.concat([ Buffer.from('CNTRPRTY', 'utf8'), // Add prefix encodingResult.data, ]); const opReturnScript = bitcoin.script.compile([ bitcoin.opcodes.OP_RETURN ?? 0x6a, opReturnData, ]); const outputs: TransactionOutput[] = [ { script: opReturnScript, value: 0, }, ]; // Add optional image data as P2WSH outputs (for testing compatibility) if (issuanceData.imageData) { const imageBuffer = Buffer.from(issuanceData.imageData, 'utf8'); const p2wshScript = bitcoin.script.compile([ bitcoin.opcodes.OP_1 ?? 0x51, imageBuffer.slice(0, Math.min(imageBuffer.length, 32)), // Limit to 32 bytes ]); outputs.push({ script: p2wshScript, value: this.dustThreshold, }); } // Calculate total output value const totalOutputValue = outputs.reduce((sum, output) => sum + output.value, 0); // Estimate fee const estimatedSize = this.estimateTransactionSize(utxos.length, outputs.length + 1); const estimatedFee = Math.ceil(estimatedSize * this.feeRate); const targetValue = totalOutputValue + estimatedFee; // Select UTXOs const selectionResult = this.selectUTXOs( utxos, targetValue, this.feeRate, this.dustThreshold, ); if (!selectionResult.success) { throw new Error(`UTXO selection failed: ${selectionResult.message}`); } // Add change output if necessary if (selectionResult.change > this.dustThreshold) { const changeScript = bitcoin.address.toOutputScript( issuanceData.sourceAddress, this.network, ); outputs.push({ script: changeScript, value: selectionResult.change, }); } // Build the transaction const transaction = this.buildRawTransaction(selectionResult.inputs, outputs); this.logger.debug?.('Bitcoin Stamp issuance transaction built successfully', { txid: transaction.getId(), size: transaction.virtualSize(), fee: selectionResult.fee, }); return transaction; } catch (error) { this.logger.error?.( 'Failed to build Bitcoin Stamp issuance transaction:', error as Record<string, unknown>, ); throw error; } } /** * Validate description length to prevent OP_RETURN overflow */ private validateDescriptionLength(description: string): void { if (!description) return; // Calculate the space used by other fields in OP_RETURN // Structure: CNTRPRTY(8) + Type(1) + AssetID(8) + Quantity(8) + Flags(1) = 26 bytes const fixedFieldsSize = 26; // Standard OP_RETURN limit is 80 bytes total const maxOpReturnSize = 80; const maxDescriptionSize = maxOpReturnSize - fixedFieldsSize; const descriptionBytes = Buffer.from(description, 'utf8').length; if (descriptionBytes > maxDescriptionSize) { throw new Error( `Description too long: ${descriptionBytes} bytes exceeds maximum ${maxDescriptionSize} bytes. ` + `Please shorten the filename or description content. ` + `Current: "${description.substring(0, 50)}${description.length > 50 ? '...' : ''}"`, ); } // Additional validation for STAMP: format if (description.startsWith('STAMP:')) { const filename = description.substring(6); // Remove 'STAMP:' prefix if (filename.length === 0) { throw new Error('STAMP: format requires a filename after the colon'); } // Warn if filename is unusually long if (filename.length > 40) { this.logger.warn?.( `Long filename detected: "${filename}" (${filename.length} chars). Consider using a shorter name.`, ); } } } /** * Build a raw transaction from inputs and outputs */ private buildRawTransaction(inputs: UTXO[], outputs: TransactionOutput[]): bitcoin.Transaction { const transaction = new bitcoin.Transaction(); transaction.version = 2; // Add inputs inputs.forEach((input) => { transaction.addInput( Buffer.from(input.txid, 'hex').reverse(), input.vout, ); }); // Add outputs outputs.forEach((output) => { if (output.script) { transaction.addOutput(output.script, output.value); } else { throw new Error('Output missing script'); } }); return transaction; } /** * Validate build data */ private validateBuildData(buildData: BitcoinStampBuildData): void { if (!buildData.data || buildData.data.length === 0) { throw new Error('Stamp data is required'); } if (!buildData.fromAddress || buildData.fromAddress.length === 0) { throw new Error('From address is required'); } // Validate data size limits (Bitcoin Stamps typically have size constraints) if (buildData.data.length > 500000) { // 500KB limit as example throw new Error('Stamp data exceeds maximum size limit'); } } /** * Get UTXOs for an address */ private async getUTXOs(address: string): Promise<UTXO[]> { this.logger.debug?.(`Fetching UTXOs for address: ${address}`); const utxos = await this.utxoProvider.getUTXOs(address); if (!utxos || utxos.length === 0) { throw new Error(`No UTXOs found for address: ${address}`); } this.logger.debug?.(`Found ${utxos.length} UTXOs for address: ${address}`, { totalValue: utxos.reduce((sum: number, utxo: UTXO) => sum + utxo.value, 0), utxoCount: utxos.length, }); return utxos; } /** * Select UTXOs for a transaction using the configured selector */ protected selectUTXOs( utxos: UTXO[], targetValue: number, feeRate: number, dustThreshold: number, algorithm: 'accumulative' | 'branch-and-bound' | 'blackjack' | 'knapsack' = 'accumulative', ): EnhancedSelectionResult { const selector = this.selectorFactory.create(algorithm); const selectionOptions: SelectionOptions = { targetValue, feeRate, dustThreshold, maxInputs: 100, // Higher limit for stamp transactions due to data size consolidate: false, }; const result: EnhancedSelectionResult = selector.select(utxos, selectionOptions); // Return structured result instead of null if ('success' in result) { if (result.success) { return { success: true, inputs: result.inputs, totalValue: result.totalValue, change: result.change, fee: result.fee, wasteMetric: result.wasteMetric, inputCount: result.inputCount, outputCount: result.outputCount, estimatedVSize: result.estimatedVSize, effectiveFeeRate: result.effectiveFeeRate, }; } else { // Selection failed - return structured error instead of null this.logger.debug?.(`Selection failed: ${result.message}`); return { success: false, reason: result.reason || SelectionFailureReason.SELECTION_FAILED, message: result.message || 'UTXO selection failed', details: result.details || { targetValue, feeRate, dustThreshold }, }; } } // Legacy format handling (shouldn't happen with updated selectors) const legacyResult = result as any; if (legacyResult && legacyResult.inputs) { return { success: true, inputs: legacyResult.inputs, totalValue: legacyResult.totalValue, change: legacyResult.change, fee: legacyResult.fee, wasteMetric: legacyResult.wasteMetric || 0, inputCount: legacyResult.inputCount || legacyResult.inputs.length, outputCount: legacyResult.outputCount || 2, estimatedVSize: legacyResult.estimatedVSize || 0, effectiveFeeRate: legacyResult.effectiveFeeRate || feeRate, }; } // No valid result found - return structured error return { success: false, reason: SelectionFailureReason.SELECTION_FAILED, message: 'Unknown selection result format', details: { targetValue, feeRate, dustThreshold }, }; } /** * Estimate transaction size for fee calculation */ protected estimateTransactionSize(numInputs: number, numOutputs: number): number { // Base transaction overhead const baseSize = 10; // Input size (assuming P2WPKH inputs) const inputSize = 68; // Witness input size // Output size const outputSize = 34; // Standard P2WPKH output // Add sizes const totalSize = baseSize + (numInputs * inputSize) + (numOutputs * outputSize); // Add 10% buffer for safety return Math.ceil(totalSize * 1.1); } }