UNPKG

@btc-stamps/tx-builder

Version:

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

627 lines (542 loc) 17 kB
/** * Enhanced PSBT Builder * BIP-174 compliant PSBT builder with advanced features */ import { Buffer } from 'node:buffer'; import * as bitcoin from 'bitcoinjs-lib'; import type { DerivationPath, IHardwareWallet, SignPsbtOptions, } from '../interfaces/hardware.interface.ts'; import { PSBTBuilder, PSBTOptions } from './psbt-builder.ts'; export interface EnhancedPSBTOptions extends PSBTOptions { /** Enable strict BIP-174 compliance */ strictCompliance?: boolean; /** Enable hardware wallet mode */ hardwareWalletMode?: boolean; /** Target hardware wallet type */ targetWalletType?: 'ledger' | 'trezor' | 'coldcard'; } export interface PSBTMetadata { /** Transaction version */ version?: number; /** Transaction locktime */ locktime?: number; /** Input count */ inputCount: number; /** Output count */ outputCount: number; /** Fee amount */ fee?: number; /** Fee rate (sat/vB) */ feeRate?: number; /** Transaction size estimate */ estimatedSize?: number; /** RBF enabled */ rbfEnabled?: boolean; } export interface InputMetadata { /** Input index */ index: number; /** Previous transaction ID */ prevTxid: string; /** Previous output index */ prevVout: number; /** Input value */ value: number; /** Input script type */ scriptType: | 'p2pkh' | 'p2wpkh' | 'p2sh-p2wpkh' | 'p2tr' | 'p2wsh' | 'p2sh' | 'unknown'; /** Sequence number */ sequence: number; /** Has witness UTXO */ hasWitnessUtxo: boolean; /** Has non-witness UTXO */ hasNonWitnessUtxo: boolean; /** BIP32 derivation paths */ derivationPaths: DerivationPath[]; /** Signatures count */ signaturesCount: number; /** Required signatures count */ signaturesRequired: number; } export interface OutputMetadata { /** Output index */ index: number; /** Output value */ value: number; /** Output address (if applicable) */ address?: string; /** Output script type */ scriptType: | 'p2pkh' | 'p2wpkh' | 'p2sh' | 'p2tr' | 'p2wsh' | 'op_return' | 'unknown'; /** Is change output */ isChange?: boolean; /** BIP32 derivation paths */ derivationPaths: DerivationPath[]; } /** * Enhanced PSBT Builder with BIP-174 compliance and advanced features */ export class EnhancedPSBTBuilder extends PSBTBuilder { private options: & Required< Omit< EnhancedPSBTOptions, | 'targetWalletType' | 'network' | 'maximumFeeRate' | 'version' | 'locktime' > > & { targetWalletType?: 'ledger' | 'trezor' | 'coldcard'; network: bitcoin.Network; maximumFeeRate: number; version: number; locktime: number; }; private inputDerivations: Map< number, Array<{ derivation: DerivationPath; publicKey: Buffer }> >; private outputDerivations: Map< number, Array<{ derivation: DerivationPath; publicKey: Buffer }> >; constructor(options: EnhancedPSBTOptions = {}) { super(options); this.options = { network: options.network ?? bitcoin.networks.bitcoin, maximumFeeRate: options.maximumFeeRate ?? 100, version: options.version ?? 2, locktime: options.locktime ?? 0, strictCompliance: options.strictCompliance ?? false, hardwareWalletMode: options.hardwareWalletMode ?? false, ...(options.targetWalletType ? { targetWalletType: options.targetWalletType } : {}), }; this.inputDerivations = new Map(); this.outputDerivations = new Map(); } /** * Add input with enhanced validation and metadata */ addEnhancedInput( txid: string, vout: number, options: { sequence?: number; witnessUtxo?: { script: Buffer; value: number }; nonWitnessUtxo?: Buffer; redeemScript?: Buffer; witnessScript?: Buffer; derivationPaths?: DerivationPath[]; publicKeys?: Buffer[]; } = {}, ): this { // Add the input using parent class super.addInput(txid, vout, options); // Store derivation information if ( options.derivationPaths && options.publicKeys && options.derivationPaths.length === options.publicKeys.length ) { const inputIndex = this.getPSBT().inputCount - 1; const derivations = options.derivationPaths.map((derivation, index) => ({ derivation, publicKey: options.publicKeys![index] as Buffer, // Explicit type assertion })); this.inputDerivations.set(inputIndex, derivations); // Add derivation to PSBT derivations.forEach(({ derivation, publicKey }) => { if (derivation && publicKey) { this.addInputDerivation(inputIndex, derivation, publicKey); } }); } return this; } /** * Add output with enhanced validation and metadata */ addEnhancedOutput( addressOrScript: string | Buffer, value: number, options: { derivationPaths?: DerivationPath[]; publicKeys?: Buffer[]; isChange?: boolean; } = {}, ): this { // Add the output using parent class super.addOutput(addressOrScript, value); // Store derivation information if ( options.derivationPaths && options.publicKeys && options.derivationPaths.length === options.publicKeys.length ) { const outputIndex = this.getPSBT().txOutputs.length - 1; const derivations = options.derivationPaths.map((derivation, index) => ({ derivation, publicKey: options.publicKeys![index] as Buffer, // Explicit type assertion })); this.outputDerivations.set(outputIndex, derivations); // Add derivation to PSBT derivations.forEach(({ derivation, publicKey }) => { if (derivation && publicKey) { this.addOutputDerivation(outputIndex, derivation, publicKey); } }); } return this; } /** * Add BIP32 derivation information to input */ addInputDerivation( inputIndex: number, derivation: DerivationPath, publicKey: Buffer, ): void { const psbt = this.getPSBT(); if (inputIndex >= psbt.inputCount) { throw new Error(`Input index ${inputIndex} out of range`); } // Add BIP32 derivation psbt.updateInput(inputIndex, { bip32Derivation: [ { masterFingerprint: derivation.masterFingerprint, path: derivation.path, pubkey: publicKey, }, ], }); } /** * Add BIP32 derivation information to output */ addOutputDerivation( outputIndex: number, derivation: DerivationPath, publicKey: Buffer, ): void { const psbt = this.getPSBT(); if (outputIndex >= psbt.txOutputs.length) { throw new Error(`Output index ${outputIndex} out of range`); } // Add BIP32 derivation psbt.updateOutput(outputIndex, { bip32Derivation: [ { masterFingerprint: derivation.masterFingerprint, path: derivation.path, pubkey: publicKey, }, ], }); } /** * Set input witness UTXO (required for segwit inputs) */ setInputWitnessUtxo( inputIndex: number, witnessUtxo: { script: Buffer; value: number }, ): void { const psbt = this.getPSBT(); if (inputIndex >= psbt.inputCount) { throw new Error(`Input index ${inputIndex} out of range`); } psbt.updateInput(inputIndex, { witnessUtxo }); } /** * Set input non-witness UTXO for legacy inputs */ setInputNonWitnessUtxo(inputIndex: number, nonWitnessUtxo: Buffer): void { const psbt = this.getPSBT(); if (inputIndex >= psbt.inputCount) { throw new Error(`Input index ${inputIndex} out of range`); } psbt.updateInput(inputIndex, { nonWitnessUtxo }); } /** * Add redeem script for P2SH inputs */ addInputRedeemScript(inputIndex: number, redeemScript: Buffer): void { const psbt = this.getPSBT(); if (inputIndex >= psbt.inputCount) { throw new Error(`Input index ${inputIndex} out of range`); } psbt.updateInput(inputIndex, { redeemScript }); } /** * Add witness script for P2WSH inputs */ addInputWitnessScript(inputIndex: number, witnessScript: Buffer): void { const psbt = this.getPSBT(); if (inputIndex >= psbt.inputCount) { throw new Error(`Input index ${inputIndex} out of range`); } psbt.updateInput(inputIndex, { witnessScript }); } /** * Get comprehensive PSBT metadata */ getMetadata(): PSBTMetadata { const psbt = this.getPSBT(); return { ...(psbt.version !== undefined && { version: psbt.version }), ...(psbt.locktime !== undefined && { locktime: psbt.locktime }), inputCount: psbt.inputCount, outputCount: psbt.txOutputs.length, ...(this.calculateFeeIfPossible() !== undefined && { fee: this.calculateFeeIfPossible()! }), ...(this.calculateFeeRateIfPossible() !== undefined && { feeRate: this.calculateFeeRateIfPossible()! }), estimatedSize: this.estimateTransactionSize(), rbfEnabled: this.isRbfEnabled(), }; } /** * Get input metadata */ getInputMetadata(inputIndex: number): InputMetadata { const psbt = this.getPSBT(); if (inputIndex >= psbt.inputCount) { throw new Error(`Input index ${inputIndex} out of range`); } const input = psbt.data.inputs[inputIndex]; const txInput = psbt.txInputs[inputIndex]; if (!input || !txInput) { throw new Error(`Input ${inputIndex} not found`); } return { index: inputIndex, prevTxid: txInput.hash.toString('hex'), prevVout: txInput.index, value: input.witnessUtxo?.value ?? 0, scriptType: this.detectInputScriptType(input), sequence: txInput.sequence ?? 0xffffffff, // Default to maximum sequence number hasWitnessUtxo: !!input.witnessUtxo, hasNonWitnessUtxo: !!input.nonWitnessUtxo, derivationPaths: this.inputDerivations.get(inputIndex)?.map((d) => d.derivation) ?? [], signaturesCount: input.partialSig?.length ?? 0, signaturesRequired: 1, // @todo(complexity): Detect multisig signature count }; } /** * Get output metadata */ getOutputMetadata(outputIndex: number): OutputMetadata { const psbt = this.getPSBT(); if (outputIndex >= psbt.txOutputs.length) { throw new Error(`Output index ${outputIndex} out of range`); } const txOutput = psbt.txOutputs[outputIndex]; if (!txOutput) { throw new Error(`Output ${outputIndex} not found`); } const address = this.extractAddressFromScript(txOutput.script); return { index: outputIndex, value: txOutput.value, ...(address && { address }), scriptType: this.detectOutputScriptType(txOutput.script), isChange: this.isChangeOutput(outputIndex), derivationPaths: this.outputDerivations.get(outputIndex)?.map((d) => d.derivation) ?? [], }; } /** * Check if Replace-by-Fee is enabled * Note: RBF should be enabled when adding inputs, not after PSBT creation */ getRbfStatus(): boolean { return this.isRbfEnabled(); } /** * Create enhanced clone with metadata preservation */ enhancedClone(): EnhancedPSBTBuilder { // Create a new PSBT from the base64 data instead of trying to combine const base64Data = this.psbt.toBase64(); const clonedPsbt = bitcoin.Psbt.fromBase64(base64Data, { network: this.options.network, }); const clone = new EnhancedPSBTBuilder(this.options); clone.psbt = clonedPsbt; // Copy derivation maps this.inputDerivations.forEach((value, key) => { clone.inputDerivations.set(key, [...value]); }); this.outputDerivations.forEach((value, key) => { clone.outputDerivations.set(key, [...value]); }); return clone; } // Private helper methods private calculateFeeIfPossible(): number | undefined { try { const psbt = this.getPSBT(); let totalInputValue = 0; let totalOutputValue = 0; // Calculate total input value for (let i = 0; i < psbt.inputCount; i++) { const input = psbt.data.inputs[i]; if (input?.witnessUtxo) { totalInputValue += input.witnessUtxo.value; } else if (input?.nonWitnessUtxo) { // For non-witness UTXO, we'd need to parse the transaction to get the value // For now, skip this calculation if we don't have witnessUtxo return undefined; } } // Calculate total output value for (let i = 0; i < psbt.txOutputs.length; i++) { totalOutputValue += psbt.txOutputs[i]?.value ?? 0; } return totalInputValue > 0 ? totalInputValue - totalOutputValue : undefined; } catch { return undefined; } } private calculateFeeRateIfPossible(): number | undefined { try { return this.getFeeRate(); } catch { return undefined; } } private estimateTransactionSize(): number { // Rough estimation - can be improved const psbt = this.getPSBT(); const baseSize = 10; // version + locktime + input/output counts const inputSize = psbt.inputCount * 150; // Approximate const outputSize = psbt.txOutputs.length * 34; // Approximate return baseSize + inputSize + outputSize; } private isRbfEnabled(): boolean { const psbt = this.getPSBT(); for (let i = 0; i < psbt.inputCount; i++) { const sequence = psbt.txInputs[i]?.sequence ?? 0xffffffff; if (sequence < 0xfffffffe) { return true; } } return false; } private detectInputScriptType(inputData: any): InputMetadata['scriptType'] { if (inputData.witnessUtxo) { const script = inputData.witnessUtxo.script; if (script.length === 22 && script[0] === 0x00 && script[1] === 0x14) { return 'p2wpkh'; } if (script.length === 34 && script[0] === 0x00 && script[1] === 0x20) { return 'p2wsh'; } } if (inputData.redeemScript) { return 'p2sh-p2wpkh'; } return 'p2pkh'; } private detectOutputScriptType(script: Buffer): OutputMetadata['scriptType'] { if (script.length === 25 && script[0] === 0x76 && script[1] === 0xa9) { return 'p2pkh'; } if (script.length === 22 && script[0] === 0x00 && script[1] === 0x14) { return 'p2wpkh'; } if (script.length === 23 && script[0] === 0xa9 && script[22] === 0x87) { return 'p2sh'; } if (script.length === 34 && script[0] === 0x00 && script[1] === 0x20) { return 'p2wsh'; } if (script.length >= 2 && script[0] === 0x6a) { return 'op_return'; } return 'unknown'; } private extractAddressFromScript(script: Buffer): string | undefined { try { return bitcoin.address.fromOutputScript(script, this.options.network); } catch { return undefined; } } private isChangeOutput(outputIndex: number): boolean { return this.outputDerivations.has(outputIndex); } /** * Validate PSBT for hardware wallet compatibility */ validateForHardwareWallet( _walletType: 'ledger' | 'trezor' | 'coldcard', ): { valid: boolean; issues: string[] } { const issues: string[] = []; const psbt = this.getPSBT(); // Check inputs have required UTXO information for (let i = 0; i < psbt.inputCount; i++) { const input = psbt.data.inputs[i]; if (!input) { issues.push(`Input ${i} missing data`); continue; } if (!input.witnessUtxo && !input.nonWitnessUtxo) { issues.push(`Input ${i} missing UTXO information`); } // Check for derivation paths (required for hardware wallets) if (!input.bip32Derivation || input.bip32Derivation.length === 0) { issues.push(`Input ${i} missing BIP32 derivation path`); } } return { valid: issues.length === 0, issues, }; } /** * Sign PSBT with hardware wallet */ async signWithHardwareWallet(hardwareWallet: IHardwareWallet): Promise<void> { const psbt = this.getPSBT(); const inputIndices: number[] = []; const derivationPaths: DerivationPath[] = []; // Collect inputs that need signing for (let i = 0; i < psbt.inputCount; i++) { const derivations = this.inputDerivations.get(i); if (!derivations || derivations.length === 0) { throw new Error(`No derivation path found for input ${i}`); } inputIndices.push(i); derivationPaths.push(derivations[0]!.derivation); } const signOptions: SignPsbtOptions = { psbt, inputIndices, derivationPaths, network: this.options.network, }; const result = await hardwareWallet.signPsbt(signOptions); if (result.errors && result.errors.length > 0) { const errorMessages = result.errors.map((e: any) => e.error).join(', '); throw new Error(`Signing errors: ${errorMessages}`); } // Update our PSBT with the signed version this.psbt = result.psbt; } }