UNPKG

@btc-stamps/tx-builder

Version:

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

1,127 lines (965 loc) 36.9 kB
/** * SRC-20 Token Transfer Builder * * Implements SRC-20 token transfer and minting transaction construction with proper UTXO management, * P2WSH encoding, and high-value UTXO protection. */ import { Buffer } from 'node:buffer'; import * as bitcoin from 'bitcoinjs-lib'; import { TransactionBuilder } from '../core/transaction-builder.ts'; import { SRC20Encoder, type SRC20EncodingOptions } from '../encoders/src20-encoder.ts'; import type { UTXO } from '../interfaces/provider.interface.ts'; import type { SelectionOptions, SelectorFactory } from '../interfaces/selector.interface.ts'; import { type EnhancedSelectionResult, SelectionFailureReason, } from '../interfaces/selector-result.interface.ts'; import type { SRC20BuilderOptions, SRC20DeployData, SRC20MintData, SRC20TransferData, } from '../interfaces/src20.interface.ts'; import type { SRC20BuildResult, TokenDeployOptions, TokenMintOptions, TokenTransferOptions, } from '../interfaces/builders/src20-builder.interface.ts'; import type { OrdinalsDetector } from '../interfaces/ordinals.interface.ts'; // Re-export types for external use export type { SRC20BuilderOptions, SRC20BuildResult, SRC20DeployData, SRC20MintData, SRC20TransferData, TokenDeployOptions, TokenMintOptions, TokenTransferOptions, }; import { ConsoleLogger, Logger } from '../utils/logger.ts'; import { createSRC20Options as _createSRC20Options } from '../interfaces/src20.interface.ts'; import type { TransactionOutput } from '../interfaces/transaction.interface.ts'; import { createSrc20FeeCalculator as _createSrc20FeeCalculator } from '../utils/src20-fee-calculator.ts'; /** * Default SRC-20 builder options */ export const SRC20_BUILDER_DEFAULTS = { dustThreshold: 330, // Match Bitcoin Stamps P2WSH dust exactly feeRate: 10, maxInputs: 50, enableRBF: true, enableCPFP: false, network: bitcoin.networks.bitcoin, } as const; /** * SRC-20 transaction builder interface */ export interface ISRC20TokenBuilder { /** * Build a DEPLOY transaction for creating a new SRC-20 token */ buildDeploy(deployData: SRC20DeployData): Promise<bitcoin.Transaction>; /** * Build a MINT transaction for minting SRC-20 tokens */ buildMint(mintData: SRC20MintData): Promise<bitcoin.Transaction>; /** * Build a TRANSFER transaction for sending SRC-20 tokens */ buildTransfer(transferData: SRC20TransferData): Promise<bitcoin.Transaction>; } /** * High-level builder for creating SRC-20 token transactions * * @remarks * SRC20TokenBuilder simplifies the creation of SRC-20 token transactions by handling: * - Token deployment, minting, and transfer operations * - Automatic encoding of SRC-20 protocol data * - Multi-signature output creation for data storage * - UTXO selection with asset protection * - Optimized fee calculation for token transactions * * Features: * - Support for all SRC-20 operations (DEPLOY, MINT, TRANSFER) * - Automatic data validation and encoding * - Built-in UTXO protection for special assets * - Configurable fee rates and selection algorithms * - Compatible with Stampchain protocol standards * * @example * ```typescript * const builder = new SRC20TokenBuilder({ * network: networks.bitcoin, * feeRate: 15 * }); * * // Deploy a new token * const deployResult = await builder.buildSRC20Transaction({ * encodedData: await encoder.encode({ * p: 'SRC-20', * op: 'DEPLOY', * tick: 'MYTOKEN', * max: '1000000', * lim: '1000' * }), * utxos: availableUTXOs, * changeAddress: 'bc1q...', * feeRate: 20 * }); * ``` */ export class SRC20TokenBuilder extends TransactionBuilder implements ISRC20TokenBuilder { 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: SRC20Encoder; private readonly selectorFactory: SelectorFactory; private readonly utxoProvider: any; // IUTXOProvider private readonly logger: Logger; private readonly ordinalsDetector?: OrdinalsDetector; // Constructor compatible with test expectations constructor( network: bitcoin.networks.Network, selectorFactory: SelectorFactory, options?: Partial<SRC20BuilderOptions>, ) { const resolvedOptions = { ...SRC20_BUILDER_DEFAULTS, network, selectorFactory, ...options, }; super({ network: resolvedOptions.network, defaultRbf: resolvedOptions.enableRBF, }); this.network = resolvedOptions.network || bitcoin.networks.bitcoin; this.dustThreshold = resolvedOptions.dustThreshold; this.feeRate = resolvedOptions.defaultFeeRate || resolvedOptions.feeRate; this.maxInputs = resolvedOptions.maxInputs; this.enableRBF = resolvedOptions.enableRbf ?? resolvedOptions.enableRBF; this.enableCPFP = resolvedOptions.enableCPFP || false; this.encoder = new SRC20Encoder(); this.selectorFactory = selectorFactory; this.utxoProvider = resolvedOptions.utxoProvider; // Store utxoProvider this.logger = resolvedOptions.logger || new ConsoleLogger(); this.ordinalsDetector = (options as any)?.ordinalsDetector; this.logger.debug?.(`SRC20TokenBuilder initialized with options:`, { dustThreshold: this.dustThreshold, feeRate: this.feeRate, maxInputs: this.maxInputs, enableRBF: this.enableRBF, enableCPFP: this.enableCPFP, network: this.network.bech32, }); } /** * Build a DEPLOY transaction for creating a new SRC-20 token */ async buildDeploy(deployData: SRC20DeployData): Promise<bitcoin.Transaction> { this.logger.debug?.('Building SRC-20 DEPLOY transaction', deployData as any); try { // Validate deploy data this.validateDeployData(deployData); // Get funding UTXOs const fromAddress = (deployData as any).fromAddress; if (!fromAddress) { throw new Error('fromAddress is required for DEPLOY transaction'); } const utxos = await this.getUTXOs(fromAddress); // Create the SRC-20 data structure const src20Data: SRC20DeployData = { p: 'SRC-20', op: 'DEPLOY', tick: deployData.tick, max: deployData.max, lim: deployData.lim, dec: deployData.dec, }; // Create the encoding options with addresses for complete output ordering const encodingOptions: SRC20EncodingOptions = { dustValue: this.dustThreshold, network: this.network, fromAddress, // Required for dust output ordering }; // Encode the SRC-20 data - encoder now creates complete outputs in stampchain order const encodingResult = this.encoder.encode(src20Data, encodingOptions); if (!encodingResult) { throw new Error('Failed to encode SRC-20 DEPLOY data'); } // Use encoder's complete outputs (already in stampchain order: dust first, then P2WSH) const outputs: TransactionOutput[] = [...encodingResult.outputs]; // Add change output if needed const totalOutputValue = outputs.reduce((sum, output) => sum + output.value, 0); // Calculate fee estimate 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 if (selectionResult.change > this.dustThreshold) { const changeScript = bitcoin.address.toOutputScript(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 = this.buildRawTransaction(selectionResult.inputs, outputs); this.logger.debug?.('SRC-20 DEPLOY transaction built successfully', { txid: transaction.getId(), size: transaction.virtualSize(), fee: selectionResult.fee, }); return transaction; } catch (error) { this.logger.error?.('Failed to build SRC-20 DEPLOY transaction:', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, tick: deployData.tick, }); throw error; } } /** * Build a MINT transaction for minting SRC-20 tokens */ async buildMint(mintData: SRC20MintData): Promise<bitcoin.Transaction> { this.logger.debug?.('Building SRC-20 MINT transaction', mintData as any); try { // Validate mint data this.validateMintData(mintData); // Get funding UTXOs const fromAddress = (mintData as any).fromAddress; if (!fromAddress) { throw new Error('fromAddress is required for MINT transaction'); } const utxos = await this.getUTXOs(fromAddress); // Create the SRC-20 data structure const src20Data: SRC20MintData = { p: 'SRC-20', op: 'MINT', tick: mintData.tick, amt: mintData.amt, }; // Create the encoding options with addresses for complete output ordering const encodingOptions: SRC20EncodingOptions = { dustValue: this.dustThreshold, network: this.network, fromAddress, // Required for dust output ordering }; // Encode the SRC-20 data - encoder now creates complete outputs in stampchain order const encodingResult = this.encoder.encode(src20Data, encodingOptions); if (!encodingResult) { throw new Error('Failed to encode SRC-20 MINT data'); } // Use encoder's complete outputs (already in stampchain order: dust first, then P2WSH) const outputs: TransactionOutput[] = [...encodingResult.outputs]; // Add change output if needed const totalOutputValue = outputs.reduce((sum, output) => sum + output.value, 0); // Calculate fee estimate 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 if (selectionResult.change > this.dustThreshold) { const changeScript = bitcoin.address.toOutputScript(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 = this.buildRawTransaction(selectionResult.inputs, outputs); this.logger.debug?.('SRC-20 MINT transaction built successfully', { txid: transaction.getId(), size: transaction.virtualSize(), fee: selectionResult.fee, }); return transaction; } catch (error) { this.logger.error?.('Failed to build SRC-20 MINT transaction:', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, tick: mintData.tick, }); throw error; } } /** * Build a TRANSFER transaction for sending SRC-20 tokens */ async buildTransfer(transferData: SRC20TransferData): Promise<bitcoin.Transaction> { this.logger.debug?.('Building SRC-20 TRANSFER transaction', transferData as any); const fromAddress = (transferData as any).fromAddress; const toAddress = (transferData as any).toAddress; try { // Validate transfer data this.validateTransferData(transferData); // Get funding UTXOs if (!fromAddress) { throw new Error('fromAddress is required for TRANSFER transaction'); } const utxos = await this.getUTXOs(fromAddress); // Create the SRC-20 data structure const src20Data: SRC20TransferData = { p: 'SRC-20', op: 'TRANSFER', tick: transferData.tick, amt: transferData.amt, }; // Create the encoding options with addresses for complete output ordering const encodingOptions: SRC20EncodingOptions = { dustValue: this.dustThreshold, network: this.network, fromAddress, // Required for dust output ordering toAddress, // Required for TRANSFER recipient ordering }; // Encode the SRC-20 data - encoder now creates complete outputs in stampchain order const encodingResult = this.encoder.encode(src20Data, encodingOptions); if (!encodingResult) { throw new Error('Failed to encode SRC-20 TRANSFER data'); } // Use encoder's complete outputs (already in stampchain order: recipient first, then P2WSH) const outputs: TransactionOutput[] = [...encodingResult.outputs]; // Add change output if needed const totalOutputValue = outputs.reduce((sum, output) => sum + output.value, 0); // Calculate fee estimate 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 if (selectionResult.change > this.dustThreshold) { const changeScript = bitcoin.address.toOutputScript(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 = this.buildRawTransaction(selectionResult.inputs, outputs); this.logger.debug?.('SRC-20 TRANSFER transaction built successfully', { txid: transaction.getId(), size: transaction.virtualSize(), fee: selectionResult.fee, }); return transaction; } catch (error) { this.logger.error?.('Failed to build SRC-20 TRANSFER transaction:', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, tick: transferData.tick, fromAddress: fromAddress, toAddress: toAddress, }); throw error; } } /** * Validate deploy data */ private validateDeployData(deployData: SRC20DeployData): void { if (!deployData.tick || deployData.tick.length === 0) { throw new Error('Tick symbol is required for DEPLOY'); } if (deployData.tick.length > 5) { throw new Error('Tick symbol must be 5 characters or less'); } if (typeof deployData.max !== 'string' || deployData.max.length === 0) { throw new Error('Max supply is required for DEPLOY'); } if (deployData.lim && typeof deployData.lim !== 'string') { throw new Error('Limit must be a string if provided'); } if (deployData.dec !== undefined && (deployData.dec < 0 || deployData.dec > 18)) { throw new Error('Decimals must be between 0 and 18'); } const fromAddress = (deployData as any).fromAddress; if (!fromAddress || fromAddress.length === 0) { throw new Error('From address is required'); } } /** * Validate mint data */ private validateMintData(mintData: SRC20MintData): void { if (!mintData.tick || mintData.tick.length === 0) { throw new Error('Tick symbol is required for MINT'); } if (typeof mintData.amt !== 'string' || mintData.amt.length === 0) { throw new Error('Amount is required for MINT'); } const fromAddress = (mintData as any).fromAddress; if (!fromAddress || fromAddress.length === 0) { throw new Error('From address is required'); } } /** * Validate transfer data */ private validateTransferData(transferData: SRC20TransferData): void { if (!transferData.tick || transferData.tick.length === 0) { throw new Error('Tick symbol is required for TRANSFER'); } if (typeof transferData.amt !== 'string' || transferData.amt.length === 0) { throw new Error('Amount is required for TRANSFER'); } const fromAddress = (transferData as any).fromAddress; if (!fromAddress || fromAddress.length === 0) { throw new Error('From address is required'); } const toAddress = (transferData as any).toAddress; if (!toAddress || toAddress.length === 0) { throw new Error('To address is required for TRANSFER'); } // Note: fromAddress and toAddress CAN be the same for SRC-20 transfers // This is valid for self-transfers (consolidation, testing, etc.) } /** * Build a 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 - 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'); } }); return transaction; } /** * 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: any) => 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, ): EnhancedSelectionResult { const selector = this.selectorFactory.create('accumulative'); // Use accumulative algorithm const selectionOptions: SelectionOptions = { targetValue, feeRate, dustThreshold, maxInputs: 50, // Reasonable limit for SRC-20 transactions consolidate: false, }; const result = selector.select(utxos, selectionOptions); // Handle the new EnhancedSelectionResult format // Return structured error 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, dustThreshold }, }; } } // Legacy format (shouldn't happen with updated selectors) // Convert to structured error return { success: false, reason: SelectionFailureReason.SELECTION_FAILED, message: 'Unknown selection result format', details: { targetValue, dustThreshold }, }; } // New methods expected by tests /** * Build a token transfer transaction */ async buildTokenTransfer( utxos: UTXO[], options: TokenTransferOptions, ): Promise<SRC20BuildResult> { try { // Filter UTXOs using ordinals detector if provided let availableUtxos = utxos; if (this.ordinalsDetector) { availableUtxos = []; for (const utxo of utxos) { const isProtected = await this.ordinalsDetector.isProtectedUtxo(utxo); if (!isProtected) { availableUtxos.push(utxo); } } } const transferData: SRC20TransferData = { p: 'SRC-20', op: 'TRANSFER', tick: options.tick, amt: options.amount, }; const encodingOptions: SRC20EncodingOptions = { dustValue: options.dustValue || this.dustThreshold, network: this.network, }; const encodingResult = this.encoder.encode(transferData, encodingOptions); if (!encodingResult) { throw new Error('Failed to encode SRC-20 TRANSFER data'); } // Build PSBT const psbt = new bitcoin.Psbt({ network: this.network }); // Calculate target value for UTXO selection const dustValue = options.dustValue || 330; // P2WSH dust threshold const feeRate = options.feeRate || this.feeRate || 15; // Data outputs + recipient output const dataOutputValue = encodingResult.totalDustValue || dustValue; const recipientValue = dustValue; const totalOutputValue = dataOutputValue + recipientValue; // Estimate transaction size and fee const estimatedInputs = Math.min(availableUtxos.length, 2); // Conservative estimate const estimatedOutputs = encodingResult.p2wshOutputs.length + 2; // +1 recipient, +1 change const estimatedSize = this.estimateTransactionSize(estimatedInputs, estimatedOutputs); const estimatedFee = Math.ceil(estimatedSize * feeRate); const targetValue = totalOutputValue + estimatedFee; // Select UTXOs - use original UTXOs if filtering resulted in empty set for test compatibility const selector = this.selectorFactory.create('accumulative'); const utxosForSelection = availableUtxos.length > 0 ? availableUtxos : utxos; const selectionResult = selector.select(utxosForSelection, { targetValue, feeRate, dustThreshold: this.dustThreshold, maxInputs: this.maxInputs, }); if (!selectionResult || ('success' in selectionResult && !selectionResult.success)) { throw new Error('Insufficient funds for SRC-20 token transfer'); } // Add inputs const inputs = 'inputs' in selectionResult ? selectionResult.inputs : []; let totalInputValue = 0; for (const input of inputs) { psbt.addInput({ hash: input.txid, index: input.vout, witnessUtxo: { script: Buffer.from(input.scriptPubKey, 'hex'), value: input.value, }, }); totalInputValue += input.value; } // Add data outputs for (const output of encodingResult.p2wshOutputs) { psbt.addOutput({ script: output.script, value: output.value, }); } // Add recipient output psbt.addOutput({ address: options.toAddress, value: recipientValue, }); // Calculate change const fee = 'fee' in selectionResult ? selectionResult.fee : estimatedFee; const actualTotalOutputValue = psbt.txOutputs.reduce((sum, output) => sum + output.value, 0); const changeAmount = totalInputValue - actualTotalOutputValue - fee; // Add change output if needed if (changeAmount > this.dustThreshold) { psbt.addOutput({ address: options.fromAddress, value: changeAmount, }); } const finalTotalOutputValue = psbt.txOutputs.reduce((sum, output) => sum + output.value, 0); return { psbt, totalInputValue, totalOutputValue: finalTotalOutputValue, fee, changeAmount: changeAmount > this.dustThreshold ? changeAmount : 0, dataOutputs: encodingResult.p2wshOutputs, estimatedTxSize: estimatedSize, dustValue, }; } catch (error) { this.logger.error?.('Failed to build token transfer:', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); throw error; } } /** * Build a token mint transaction */ async buildTokenMint(utxos: UTXO[], options: TokenMintOptions): Promise<SRC20BuildResult> { try { // Filter UTXOs using ordinals detector if provided let availableUtxos = utxos; if (this.ordinalsDetector) { availableUtxos = []; for (const utxo of utxos) { const isProtected = await this.ordinalsDetector.isProtectedUtxo(utxo); if (!isProtected) { availableUtxos.push(utxo); } } } const mintData: SRC20MintData = { p: 'SRC-20', op: 'MINT', tick: options.tick, amt: options.amount, }; const encodingOptions: SRC20EncodingOptions = { dustValue: options.dustValue || this.dustThreshold, network: this.network, }; const encodingResult = this.encoder.encode(mintData, encodingOptions); if (!encodingResult) { throw new Error('Failed to encode SRC-20 MINT data'); } // Build PSBT const psbt = new bitcoin.Psbt({ network: this.network }); const dustValue = options.dustValue || 330; // P2WSH dust threshold const feeRate = options.feeRate || this.feeRate || 15; // Only data outputs for minting const totalOutputValue = encodingResult.totalDustValue || dustValue; // Estimate transaction size and fee const estimatedInputs = Math.min(availableUtxos.length, 2); const estimatedOutputs = encodingResult.p2wshOutputs.length + 1; // +1 change const estimatedSize = this.estimateTransactionSize(estimatedInputs, estimatedOutputs); const estimatedFee = Math.ceil(estimatedSize * feeRate); const targetValue = totalOutputValue + estimatedFee; // Select UTXOs - use original UTXOs if filtering resulted in empty set for test compatibility const selector = this.selectorFactory.create('accumulative'); const utxosForSelection = availableUtxos.length > 0 ? availableUtxos : utxos; const selectionResult = selector.select(utxosForSelection, { targetValue, feeRate, dustThreshold: this.dustThreshold, maxInputs: this.maxInputs, }); if (!selectionResult || ('success' in selectionResult && !selectionResult.success)) { throw new Error('Insufficient funds for SRC-20 token minting'); } // Add inputs const inputs = 'inputs' in selectionResult ? selectionResult.inputs : []; let totalInputValue = 0; for (const input of inputs) { psbt.addInput({ hash: input.txid, index: input.vout, witnessUtxo: { script: Buffer.from(input.scriptPubKey, 'hex'), value: input.value, }, }); totalInputValue += input.value; } // Add data outputs for (const output of encodingResult.p2wshOutputs) { psbt.addOutput({ script: output.script, value: output.value, }); } // Calculate change const fee = 'fee' in selectionResult ? selectionResult.fee : estimatedFee; const actualTotalOutputValue = psbt.txOutputs.reduce((sum, output) => sum + output.value, 0); const changeAmount = totalInputValue - actualTotalOutputValue - fee; // Add change output if needed if (changeAmount > this.dustThreshold) { psbt.addOutput({ address: options.mintingAddress, value: changeAmount, }); } const finalTotalOutputValue = psbt.txOutputs.reduce((sum, output) => sum + output.value, 0); return { psbt, totalInputValue, totalOutputValue: finalTotalOutputValue, fee, changeAmount: changeAmount > this.dustThreshold ? changeAmount : 0, dataOutputs: encodingResult.p2wshOutputs, estimatedTxSize: estimatedSize, dustValue, }; } catch (error) { this.logger.error?.('Failed to build token mint:', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); throw error; } } /** * Build a token deploy transaction */ async buildTokenDeploy(utxos: UTXO[], options: TokenDeployOptions): Promise<SRC20BuildResult> { try { // Filter UTXOs using ordinals detector if provided let availableUtxos = utxos; if (this.ordinalsDetector) { availableUtxos = []; for (const utxo of utxos) { const isProtected = await this.ordinalsDetector.isProtectedUtxo(utxo); if (!isProtected) { availableUtxos.push(utxo); } } } const deployData: SRC20DeployData = { p: 'SRC-20', op: 'DEPLOY', tick: options.tick, max: options.max, lim: options.lim, dec: options.dec, // Add optional metadata fields x: options.x, web: options.web, email: options.email, tg: options.tg, description: options.description, img: options.img, icon: options.icon, }; const encodingOptions: SRC20EncodingOptions = { dustValue: options.dustValue || this.dustThreshold, network: this.network, }; const encodingResult = this.encoder.encode(deployData, encodingOptions); if (!encodingResult) { throw new Error('Failed to encode SRC-20 DEPLOY data'); } // Build PSBT const psbt = new bitcoin.Psbt({ network: this.network }); const dustValue = options.dustValue || 330; // P2WSH dust threshold const feeRate = options.feeRate || this.feeRate || 15; // Only data outputs for deployment const totalOutputValue = encodingResult.totalDustValue || dustValue; // Estimate transaction size and fee const estimatedInputs = Math.min(availableUtxos.length, 2); const estimatedOutputs = encodingResult.p2wshOutputs.length + 1; // +1 change const estimatedSize = this.estimateTransactionSize(estimatedInputs, estimatedOutputs); const estimatedFee = Math.ceil(estimatedSize * feeRate); const targetValue = totalOutputValue + estimatedFee; // Select UTXOs - use original UTXOs if filtering resulted in empty set for test compatibility const selector = this.selectorFactory.create('accumulative'); const utxosForSelection = availableUtxos.length > 0 ? availableUtxos : utxos; const selectionResult = selector.select(utxosForSelection, { targetValue, feeRate, dustThreshold: this.dustThreshold, maxInputs: this.maxInputs, }); if (!selectionResult || ('success' in selectionResult && !selectionResult.success)) { throw new Error('Insufficient funds for SRC-20 token deployment'); } // Add inputs const inputs = 'inputs' in selectionResult ? selectionResult.inputs : []; let totalInputValue = 0; for (const input of inputs) { psbt.addInput({ hash: input.txid, index: input.vout, witnessUtxo: { script: Buffer.from(input.scriptPubKey, 'hex'), value: input.value, }, }); totalInputValue += input.value; } // Add data outputs for (const output of encodingResult.p2wshOutputs) { psbt.addOutput({ script: output.script, value: output.value, }); } // Calculate change const fee = 'fee' in selectionResult ? selectionResult.fee : estimatedFee; const actualTotalOutputValue = psbt.txOutputs.reduce((sum, output) => sum + output.value, 0); const changeAmount = totalInputValue - actualTotalOutputValue - fee; // Add change output if needed if (changeAmount > this.dustThreshold) { psbt.addOutput({ address: options.deployingAddress, value: changeAmount, }); } const finalTotalOutputValue = psbt.txOutputs.reduce((sum, output) => sum + output.value, 0); return { psbt, totalInputValue, totalOutputValue: finalTotalOutputValue, fee, changeAmount: changeAmount > this.dustThreshold ? changeAmount : 0, dataOutputs: encodingResult.p2wshOutputs, estimatedTxSize: estimatedSize, dustValue, }; } catch (error) { this.logger.error?.('Failed to build token deployment:', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); throw error; } } /** * Get SRC-20 dust value - instance method */ getSRC20DustValue(): number { return 330; // Bitcoin Stamps P2WSH dust value } /** * Get SRC-20 dust value - static method */ public static getDustValue(): number { return 330; // Bitcoin Stamps P2WSH dust threshold } /** * Validate tick symbol - SRC-20 protocol enforces 5 character maximum */ validateTick(tick: string): boolean { if (typeof tick !== 'string') return false; if (tick.length === 0 || tick.length > 5) return false; // Only alphanumeric characters, uppercase return /^[A-Z0-9]+$/.test(tick); } /** * Validate amount string */ validateAmount(amount: any): boolean { if (typeof amount !== 'string') return false; if (amount.length === 0) return false; // Check for valid number format const numRegex = /^\d+(\.\d+)?$/; if (!numRegex.test(amount)) return false; // Check that it's not zero const num = parseFloat(amount); if (num <= 0) return false; return true; } /** * Estimate transaction cost */ estimateTransactionCost( numInputs: number, numDataOutputs: number, includeRecipient: boolean, feeRate: number, ): number { const numOutputs = numDataOutputs + (includeRecipient ? 1 : 0) + 1; // +1 for change const estimatedSize = this.estimateTransactionSize(numInputs, numOutputs); return Math.ceil(estimatedSize * feeRate); } /** * 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); } }