/**
* @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;