UNPKG

j-bitcoin

Version:

Comprehensive JavaScript/TypeScript Bitcoin (BTC) wallet library with custodial and non-custodial support, hierarchical deterministic keys, threshold signatures, and advanced cryptographic features

825 lines (703 loc) 25.1 kB
/** * @fileoverview Transaction builder with signing support * @description Build and sign Bitcoin transactions (Legacy, SegWit, Taproot) * @version 1.0.0 * @author yfbsei * @license ISC */ import { createHash } from 'node:crypto'; import { getScriptPubKey, decodeAddress, hash160 } from '../utils/address-helpers.js'; import { SighashCalculator, BIP143, BIP341, LegacySighash, SIGHASH } from './sighash.js'; import { ScriptBuilder } from './script-builder.js'; import { WitnessBuilder } from './witness-builder.js'; import { ECDSA } from '../core/crypto/signatures/ecdsa.js'; import { Schnorr } from '../core/crypto/signatures/schnorr-BIP340.js'; /** * Custom error class for transaction building * @class TransactionBuilderError * @extends Error */ class TransactionBuilderError extends Error { constructor(message, code, details = {}) { super(message); this.name = 'TransactionBuilderError'; this.code = code; this.details = details; } } /** * Transaction building constants * @constant {Object} */ const TX_CONSTANTS = { VERSION: 2, DEFAULT_SEQUENCE: 0xffffffff, RBF_SEQUENCE: 0xfffffffd, DEFAULT_LOCKTIME: 0, DUST_LIMIT: 546, SIGHASH_ALL: 0x01, SIGHASH_NONE: 0x02, SIGHASH_SINGLE: 0x03, SIGHASH_ANYONECANPAY: 0x80, SIGHASH_DEFAULT: 0x00 // Taproot }; /** * Bitcoin transaction builder with SegWit/Taproot signing support * @class TransactionBuilder */ class TransactionBuilder { /** * Create a transaction builder * @param {string} [network='main'] - Network type * @param {Object} [options={}] - Builder options */ constructor(network = 'main', options = {}) { this.network = network; this.version = options.version || TX_CONSTANTS.VERSION; this.locktime = options.locktime || TX_CONSTANTS.DEFAULT_LOCKTIME; this.inputs = []; this.outputs = []; this.witnesses = []; this.schnorr = new Schnorr(); } /** * Add a transaction input * @param {Object} input - Input details * @param {string} input.txid - Previous transaction ID * @param {number} input.vout - Output index * @param {number} [input.value] - UTXO value in satoshis (required for signing) * @param {Buffer|string} [input.scriptPubKey] - Previous output script * @param {string} [input.address] - Previous output address * @param {string} [input.type] - Address type (p2pkh, p2wpkh, p2tr) * @returns {TransactionBuilder} this */ addInput(input) { if (!input.txid || typeof input.txid !== 'string') { throw new TransactionBuilderError('Invalid txid', 'INVALID_TXID'); } if (typeof input.vout !== 'number' || input.vout < 0) { throw new TransactionBuilderError('Invalid vout', 'INVALID_VOUT'); } // Determine type from address if not provided let type = input.type || input.addressType; let scriptPubKey = input.scriptPubKey; if (input.address && !type) { const decoded = decodeAddress(input.address); type = decoded.type; } if (input.address && !scriptPubKey) { scriptPubKey = getScriptPubKey(input.address); } this.inputs.push({ txid: input.txid, vout: input.vout, sequence: input.sequence ?? TX_CONSTANTS.DEFAULT_SEQUENCE, value: input.value, scriptPubKey: scriptPubKey, address: input.address, type: type || 'p2wpkh', scriptSig: Buffer.alloc(0), signed: false }); // Initialize witness slot this.witnesses.push([]); return this; } /** * Add a transaction output * @param {Object} output - Output details * @param {string} [output.address] - Destination address * @param {number} output.value - Amount in satoshis * @param {Buffer} [output.scriptPubKey] - Custom scriptPubKey * @returns {TransactionBuilder} this */ addOutput(output) { if (!output.address && !output.scriptPubKey) { throw new TransactionBuilderError('Address or scriptPubKey required', 'MISSING_OUTPUT'); } if (typeof output.value !== 'number' || output.value < 0) { throw new TransactionBuilderError('Invalid output value', 'INVALID_VALUE'); } // Allow 0 value for OP_RETURN const isOpReturn = output.scriptPubKey && output.scriptPubKey[0] === 0x6a; if (output.value < TX_CONSTANTS.DUST_LIMIT && output.value !== 0 && !isOpReturn) { throw new TransactionBuilderError('Output below dust limit', 'DUST_OUTPUT'); } let scriptPubKey = output.scriptPubKey; if (!scriptPubKey && output.address) { scriptPubKey = getScriptPubKey(output.address); } this.outputs.push({ value: output.value, scriptPubKey, address: output.address }); return this; } /** * Add an OP_RETURN output * @param {Buffer|string} data - Data to embed (max 80 bytes) * @returns {TransactionBuilder} this */ addOpReturn(data) { const script = ScriptBuilder.createOpReturn(data); return this.addOutput({ value: 0, scriptPubKey: script }); } /** * Set locktime * @param {number} locktime - Locktime value * @returns {TransactionBuilder} this */ setLocktime(locktime) { this.locktime = locktime; return this; } /** * Set version * @param {number} version - Transaction version * @returns {TransactionBuilder} this */ setVersion(version) { this.version = version; return this; } /** * Enable Replace-by-Fee for an input * @param {number} [inputIndex=null] - Specific input index, or all if null * @returns {TransactionBuilder} this */ enableRBF(inputIndex = null) { if (inputIndex !== null) { if (inputIndex < 0 || inputIndex >= this.inputs.length) { throw new TransactionBuilderError('Invalid input index', 'INVALID_INDEX'); } this.inputs[inputIndex].sequence = TX_CONSTANTS.RBF_SEQUENCE; } else { for (const input of this.inputs) { input.sequence = TX_CONSTANTS.RBF_SEQUENCE; } } return this; } /** * Set custom sequence for an input (for CSV/CLTV) * @param {number} inputIndex - Input index * @param {number} sequence - Sequence value * @returns {TransactionBuilder} this */ setInputSequence(inputIndex, sequence) { if (inputIndex < 0 || inputIndex >= this.inputs.length) { throw new TransactionBuilderError('Invalid input index', 'INVALID_INDEX'); } this.inputs[inputIndex].sequence = sequence; return this; } /** * Sign a single input * @param {number} inputIndex - Index of input to sign * @param {Buffer|string} privateKey - Private key (32 bytes) * @param {number} [sighashType=0x01] - Sighash type * @returns {Promise<TransactionBuilder>} this */ async signInput(inputIndex, privateKey, sighashType = TX_CONSTANTS.SIGHASH_ALL) { if (inputIndex < 0 || inputIndex >= this.inputs.length) { throw new TransactionBuilderError('Invalid input index', 'INVALID_INDEX'); } const input = this.inputs[inputIndex]; if (input.value === undefined) { throw new TransactionBuilderError( 'Input value required for signing', 'MISSING_VALUE', { inputIndex } ); } // Normalize private key const keyBuffer = Buffer.isBuffer(privateKey) ? privateKey : Buffer.from(privateKey, 'hex'); // Get public key const publicKey = ECDSA.getPublicKey(keyBuffer, true); // Build transaction object for sighash const tx = this._buildForSigning(); // Get prevout info const prevout = { value: input.value, scriptPubKey: input.scriptPubKey, type: input.type }; const inputType = input.type || 'p2wpkh'; switch (inputType) { case 'p2tr': { // Taproot key-path signing const allPrevouts = this.inputs.map((inp, i) => ({ value: inp.value, scriptPubKey: inp.scriptPubKey })); const taprootSighashType = sighashType === TX_CONSTANTS.SIGHASH_ALL ? TX_CONSTANTS.SIGHASH_DEFAULT : sighashType; const sighash = BIP341.calculate(tx, inputIndex, allPrevouts, taprootSighashType); // Sign with Schnorr const sigResult = await this.schnorr.sign(keyBuffer, sighash); let signature = sigResult.signature; // Append sighash type if not DEFAULT if (taprootSighashType !== TX_CONSTANTS.SIGHASH_DEFAULT) { signature = Buffer.concat([signature, Buffer.from([taprootSighashType])]); } // Build P2TR key-path witness this.witnesses[inputIndex] = WitnessBuilder.buildP2TRKeyPath(signature); break; } case 'p2wpkh': { // SegWit P2WPKH signing const pubkeyHash = hash160(publicKey); const sighash = BIP143.forP2WPKH(tx, inputIndex, pubkeyHash, input.value, sighashType); // Sign with ECDSA const sigResult = ECDSA.sign(keyBuffer, sighash); // Append sighash type to DER signature const signature = Buffer.concat([sigResult.der, Buffer.from([sighashType])]); // Build P2WPKH witness this.witnesses[inputIndex] = WitnessBuilder.buildP2WPKH(signature, publicKey); break; } case 'p2wsh': { // SegWit P2WSH signing (requires witnessScript) if (!input.witnessScript) { throw new TransactionBuilderError( 'P2WSH requires witnessScript', 'MISSING_WITNESS_SCRIPT', { inputIndex } ); } const witnessScript = Buffer.isBuffer(input.witnessScript) ? input.witnessScript : Buffer.from(input.witnessScript, 'hex'); // Calculate script code with length prefix const scriptCode = Buffer.concat([ this._encodeVarInt(witnessScript.length), witnessScript ]); const sighash = BIP143.calculate(tx, inputIndex, scriptCode, input.value, sighashType); // Sign with ECDSA const sigResult = ECDSA.sign(keyBuffer, sighash); const signature = Buffer.concat([sigResult.der, Buffer.from([sighashType])]); // Build P2WSH witness (caller should add other signatures for multisig) if (!this.witnesses[inputIndex] || this.witnesses[inputIndex].length === 0) { this.witnesses[inputIndex] = [Buffer.alloc(0)]; // OP_0 for CHECKMULTISIG bug } this.witnesses[inputIndex].push(signature); // Store witnessScript for finalization input._witnessScript = witnessScript; break; } case 'p2sh': case 'p2sh-p2pkh': { // Legacy P2SH (requires redeemScript) if (!input.redeemScript) { throw new TransactionBuilderError( 'P2SH requires redeemScript', 'MISSING_REDEEM_SCRIPT', { inputIndex } ); } const redeemScript = Buffer.isBuffer(input.redeemScript) ? input.redeemScript : Buffer.from(input.redeemScript, 'hex'); const sighash = LegacySighash.calculate(tx, inputIndex, redeemScript, sighashType); // Sign with ECDSA const sigResult = ECDSA.sign(keyBuffer, sighash); const signature = Buffer.concat([sigResult.der, Buffer.from([sighashType])]); // Build P2SH scriptSig: <sig> <pubkey> <redeemScript> this.inputs[inputIndex].scriptSig = ScriptBuilder.createP2SHScriptSig( [signature, publicKey], redeemScript ); break; } case 'p2sh-p2wpkh': { // Wrapped SegWit (BIP49): P2SH containing P2WPKH const pubkeyHash = hash160(publicKey); // Create P2WPKH redeemScript: OP_0 <20 bytes> const redeemScript = ScriptBuilder.createP2WPKH(pubkeyHash); // BIP143 sighash for the inner P2WPKH const sighash = BIP143.forP2WPKH(tx, inputIndex, pubkeyHash, input.value, sighashType); // Sign with ECDSA const sigResult = ECDSA.sign(keyBuffer, sighash); const signature = Buffer.concat([sigResult.der, Buffer.from([sighashType])]); // scriptSig contains only the redeemScript push this.inputs[inputIndex].scriptSig = Buffer.concat([ Buffer.from([redeemScript.length]), redeemScript ]); // Witness contains signature and public key this.witnesses[inputIndex] = WitnessBuilder.buildP2WPKH(signature, publicKey); break; } case 'p2sh-p2wsh': { // Wrapped SegWit P2SH-P2WSH if (!input.witnessScript) { throw new TransactionBuilderError( 'P2SH-P2WSH requires witnessScript', 'MISSING_WITNESS_SCRIPT', { inputIndex } ); } const witnessScript = Buffer.isBuffer(input.witnessScript) ? input.witnessScript : Buffer.from(input.witnessScript, 'hex'); // Create P2WSH redeemScript: OP_0 <32-byte SHA256 of witnessScript> const { createHash } = await import('node:crypto'); const scriptHash = createHash('sha256').update(witnessScript).digest(); const redeemScript = ScriptBuilder.createP2WSH(scriptHash); // Calculate script code const scriptCode = Buffer.concat([ this._encodeVarInt(witnessScript.length), witnessScript ]); const sighash = BIP143.calculate(tx, inputIndex, scriptCode, input.value, sighashType); // Sign with ECDSA const sigResult = ECDSA.sign(keyBuffer, sighash); const signature = Buffer.concat([sigResult.der, Buffer.from([sighashType])]); // scriptSig contains only the redeemScript push this.inputs[inputIndex].scriptSig = Buffer.concat([ Buffer.from([redeemScript.length]), redeemScript ]); // Build witness if (!this.witnesses[inputIndex] || this.witnesses[inputIndex].length === 0) { this.witnesses[inputIndex] = [Buffer.alloc(0)]; } this.witnesses[inputIndex].push(signature); input._witnessScript = witnessScript; break; } case 'p2pkh': default: { // Legacy P2PKH signing const sighash = LegacySighash.calculate(tx, inputIndex, input.scriptPubKey, sighashType); // Sign with ECDSA const sigResult = ECDSA.sign(keyBuffer, sighash); // Append sighash type const signature = Buffer.concat([sigResult.der, Buffer.from([sighashType])]); // Build P2PKH scriptSig this.inputs[inputIndex].scriptSig = ScriptBuilder.createP2PKHScriptSig(signature, publicKey); break; } } this.inputs[inputIndex].signed = true; return this; } /** * Sign all inputs with the same private key * @param {Buffer|string} privateKey - Private key * @param {number} [sighashType=0x01] - Sighash type * @returns {Promise<TransactionBuilder>} this */ async signAllInputs(privateKey, sighashType = TX_CONSTANTS.SIGHASH_ALL) { for (let i = 0; i < this.inputs.length; i++) { await this.signInput(i, privateKey, sighashType); } return this; } /** * Sign inputs with different keys * @param {Array<Object>} signingInfo - Array of {inputIndex, privateKey, sighashType} * @returns {Promise<TransactionBuilder>} this */ async signInputs(signingInfo) { for (const info of signingInfo) { await this.signInput( info.inputIndex, info.privateKey, info.sighashType ?? TX_CONSTANTS.SIGHASH_ALL ); } return this; } /** * Manually add witness data to an input * @param {number} inputIndex - Input index * @param {Array<Buffer>} witnessStack - Witness items * @returns {TransactionBuilder} this */ addWitness(inputIndex, witnessStack) { if (inputIndex < 0 || inputIndex >= this.inputs.length) { throw new TransactionBuilderError('Invalid input index', 'INVALID_INDEX'); } this.witnesses[inputIndex] = witnessStack; this.inputs[inputIndex].signed = true; return this; } /** * Build transaction object for signing * @private */ _buildForSigning() { return { version: this.version, inputs: this.inputs.map(input => ({ txid: input.txid, vout: input.vout, sequence: input.sequence, scriptSig: input.scriptSig || Buffer.alloc(0) })), outputs: this.outputs.map(output => ({ value: output.value, scriptPubKey: output.scriptPubKey })), locktime: this.locktime }; } /** * Build the final transaction * @returns {Object} Built transaction object * @throws {TransactionBuilderError} If no inputs or outputs */ build() { if (this.inputs.length === 0) { throw new TransactionBuilderError('No inputs added', 'NO_INPUTS'); } if (this.outputs.length === 0) { throw new TransactionBuilderError('No outputs added', 'NO_OUTPUTS'); } // Check for any SegWit/Taproot inputs requiring witnesses const hasWitness = this.witnesses.some(w => w && w.length > 0); return { version: this.version, inputs: this.inputs.map(input => ({ txid: input.txid, vout: input.vout, sequence: input.sequence, scriptSig: input.scriptSig || Buffer.alloc(0) })), outputs: this.outputs.map(output => ({ value: output.value, scriptPubKey: output.scriptPubKey })), locktime: this.locktime, witnesses: hasWitness ? this.witnesses : [] }; } /** * Serialize transaction to raw bytes * @param {Object} [transaction=null] - Transaction to serialize * @returns {Buffer} Serialized transaction */ serialize(transaction = null) { const tx = transaction || this.build(); const parts = []; // Version (4 bytes LE) const version = Buffer.alloc(4); version.writeInt32LE(tx.version, 0); parts.push(version); // Check for witness data const hasWitness = tx.witnesses && tx.witnesses.length > 0 && tx.witnesses.some(w => w && w.length > 0); if (hasWitness) { // SegWit marker and flag parts.push(Buffer.from([0x00, 0x01])); } // Input count parts.push(this._encodeVarInt(tx.inputs.length)); // Inputs for (const input of tx.inputs) { // Txid (reversed) const txidBuffer = typeof input.txid === 'string' ? Buffer.from(input.txid, 'hex').reverse() : Buffer.from(input.txid).reverse(); parts.push(txidBuffer); // Vout const vout = Buffer.alloc(4); vout.writeUInt32LE(input.vout, 0); parts.push(vout); // ScriptSig const scriptSig = input.scriptSig || Buffer.alloc(0); parts.push(this._encodeVarInt(scriptSig.length)); if (scriptSig.length > 0) { parts.push(scriptSig); } // Sequence const sequence = Buffer.alloc(4); sequence.writeUInt32LE(input.sequence, 0); parts.push(sequence); } // Output count parts.push(this._encodeVarInt(tx.outputs.length)); // Outputs for (const output of tx.outputs) { // Value (8 bytes LE) const value = Buffer.alloc(8); value.writeBigUInt64LE(BigInt(output.value), 0); parts.push(value); // ScriptPubKey const script = Buffer.isBuffer(output.scriptPubKey) ? output.scriptPubKey : Buffer.from(output.scriptPubKey, 'hex'); parts.push(this._encodeVarInt(script.length)); parts.push(script); } // Witness data if (hasWitness) { for (let i = 0; i < tx.inputs.length; i++) { const witness = tx.witnesses[i] || []; parts.push(this._encodeVarInt(witness.length)); for (const item of witness) { const itemBuf = Buffer.isBuffer(item) ? item : Buffer.from(item, 'hex'); parts.push(this._encodeVarInt(itemBuf.length)); if (itemBuf.length > 0) { parts.push(itemBuf); } } } } // Locktime (4 bytes LE) const locktime = Buffer.alloc(4); locktime.writeUInt32LE(tx.locktime, 0); parts.push(locktime); return Buffer.concat(parts); } /** * Get serialized transaction as hex string * @returns {string} Hex-encoded transaction */ toHex() { return this.serialize().toString('hex'); } /** * Get transaction ID (txid) * @param {Object} [transaction=null] - Transaction object * @returns {string} Transaction ID in hex */ getTxid(transaction = null) { const tx = transaction || this.build(); // For txid, we serialize without witness data const txCopy = { ...tx, witnesses: [] }; const serialized = this.serialize(txCopy); const hash = createHash('sha256') .update(createHash('sha256').update(serialized).digest()) .digest(); return hash.reverse().toString('hex'); } /** * Get witness transaction ID (wtxid) * @returns {string} Witness transaction ID in hex */ getWtxid() { const serialized = this.serialize(); const hash = createHash('sha256') .update(createHash('sha256').update(serialized).digest()) .digest(); return hash.reverse().toString('hex'); } /** * Calculate transaction fee given fee rate * @param {number} [feeRate=1] - Satoshis per vbyte * @returns {number} Fee in satoshis */ calculateFee(feeRate = 1) { const virtualSize = this.getVirtualSize(); return Math.ceil(virtualSize * feeRate); } /** * Get virtual size (vsize) for fee calculation * @returns {number} Virtual size in vbytes */ getVirtualSize() { const tx = this.build(); // Serialize without witness const baseSerialized = this.serialize({ ...tx, witnesses: [] }); const baseSize = baseSerialized.length; const hasWitness = tx.witnesses && tx.witnesses.length > 0 && tx.witnesses.some(w => w && w.length > 0); if (!hasWitness) { return baseSize; } // Serialize with witness const fullSerialized = this.serialize(tx); const totalSize = fullSerialized.length; // vsize = (base_size * 3 + total_size) / 4 return Math.ceil((baseSize * 3 + totalSize) / 4); } /** * Get transaction weight * @returns {number} Weight units */ getWeight() { const tx = this.build(); const baseSerialized = this.serialize({ ...tx, witnesses: [] }); const baseSize = baseSerialized.length; const hasWitness = tx.witnesses && tx.witnesses.length > 0 && tx.witnesses.some(w => w && w.length > 0); if (!hasWitness) { return baseSize * 4; } const fullSerialized = this.serialize(tx); const witnessSize = fullSerialized.length - baseSize; return baseSize * 3 + fullSerialized.length; } /** * Check if all inputs are signed * @returns {boolean} True if all inputs signed */ isFullySigned() { return this.inputs.every(input => input.signed); } /** * Encode variable-length integer * @private */ _encodeVarInt(n) { if (n < 0xfd) { return Buffer.from([n]); } else if (n <= 0xffff) { const buf = Buffer.alloc(3); buf[0] = 0xfd; buf.writeUInt16LE(n, 1); return buf; } else if (n <= 0xffffffff) { const buf = Buffer.alloc(5); buf[0] = 0xfe; buf.writeUInt32LE(n, 1); return buf; } else { const buf = Buffer.alloc(9); buf[0] = 0xff; buf.writeBigUInt64LE(BigInt(n), 1); return buf; } } /** * Clone the builder * @returns {TransactionBuilder} Cloned builder */ clone() { const builder = new TransactionBuilder(this.network, { version: this.version, locktime: this.locktime }); builder.inputs = this.inputs.map(i => ({ ...i })); builder.outputs = this.outputs.map(o => ({ ...o })); builder.witnesses = this.witnesses.map(w => w ? [...w] : []); return builder; } /** * Reset the builder * @returns {TransactionBuilder} this */ reset() { this.inputs = []; this.outputs = []; this.witnesses = []; this.locktime = TX_CONSTANTS.DEFAULT_LOCKTIME; return this; } } export { TransactionBuilder, TransactionBuilderError, TX_CONSTANTS }; export default TransactionBuilder;