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

645 lines (576 loc) 18 kB
/** * @fileoverview Bitcoin Script Builder * @description Build and parse Bitcoin scripts for all address types * @version 1.0.0 * @author yfbsei * @license ISC */ import { createHash } from 'node:crypto'; import rmd160 from '../core/crypto/hash/ripemd160.js'; /** * Bitcoin opcodes * @constant {Object} */ const OPCODES = { // Push operations OP_0: 0x00, OP_FALSE: 0x00, OP_PUSHDATA1: 0x4c, OP_PUSHDATA2: 0x4d, OP_PUSHDATA4: 0x4e, OP_1NEGATE: 0x4f, OP_RESERVED: 0x50, OP_1: 0x51, OP_TRUE: 0x51, OP_2: 0x52, OP_3: 0x53, OP_4: 0x54, OP_5: 0x55, OP_6: 0x56, OP_7: 0x57, OP_8: 0x58, OP_9: 0x59, OP_10: 0x5a, OP_11: 0x5b, OP_12: 0x5c, OP_13: 0x5d, OP_14: 0x5e, OP_15: 0x5f, OP_16: 0x60, // Flow control OP_NOP: 0x61, OP_VER: 0x62, OP_IF: 0x63, OP_NOTIF: 0x64, OP_VERIF: 0x65, OP_VERNOTIF: 0x66, OP_ELSE: 0x67, OP_ENDIF: 0x68, OP_VERIFY: 0x69, OP_RETURN: 0x6a, // Stack operations OP_TOALTSTACK: 0x6b, OP_FROMALTSTACK: 0x6c, OP_2DROP: 0x6d, OP_2DUP: 0x6e, OP_3DUP: 0x6f, OP_2OVER: 0x70, OP_2ROT: 0x71, OP_2SWAP: 0x72, OP_IFDUP: 0x73, OP_DEPTH: 0x74, OP_DROP: 0x75, OP_DUP: 0x76, OP_NIP: 0x77, OP_OVER: 0x78, OP_PICK: 0x79, OP_ROLL: 0x7a, OP_ROT: 0x7b, OP_SWAP: 0x7c, OP_TUCK: 0x7d, // Splice operations OP_CAT: 0x7e, OP_SUBSTR: 0x7f, OP_LEFT: 0x80, OP_RIGHT: 0x81, OP_SIZE: 0x82, // Bitwise logic OP_INVERT: 0x83, OP_AND: 0x84, OP_OR: 0x85, OP_XOR: 0x86, OP_EQUAL: 0x87, OP_EQUALVERIFY: 0x88, // Arithmetic OP_1ADD: 0x8b, OP_1SUB: 0x8c, OP_2MUL: 0x8d, OP_2DIV: 0x8e, OP_NEGATE: 0x8f, OP_ABS: 0x90, OP_NOT: 0x91, OP_0NOTEQUAL: 0x92, OP_ADD: 0x93, OP_SUB: 0x94, OP_MUL: 0x95, OP_DIV: 0x96, OP_MOD: 0x97, OP_LSHIFT: 0x98, OP_RSHIFT: 0x99, OP_BOOLAND: 0x9a, OP_BOOLOR: 0x9b, OP_NUMEQUAL: 0x9c, OP_NUMEQUALVERIFY: 0x9d, OP_NUMNOTEQUAL: 0x9e, OP_LESSTHAN: 0x9f, OP_GREATERTHAN: 0xa0, OP_LESSTHANOREQUAL: 0xa1, OP_GREATERTHANOREQUAL: 0xa2, OP_MIN: 0xa3, OP_MAX: 0xa4, OP_WITHIN: 0xa5, // Crypto OP_RIPEMD160: 0xa6, OP_SHA1: 0xa7, OP_SHA256: 0xa8, OP_HASH160: 0xa9, OP_HASH256: 0xaa, OP_CODESEPARATOR: 0xab, OP_CHECKSIG: 0xac, OP_CHECKSIGVERIFY: 0xad, OP_CHECKMULTISIG: 0xae, OP_CHECKMULTISIGVERIFY: 0xaf, // Expansion OP_NOP1: 0xb0, OP_CHECKLOCKTIMEVERIFY: 0xb1, OP_CLTV: 0xb1, OP_CHECKSEQUENCEVERIFY: 0xb2, OP_CSV: 0xb2, OP_NOP4: 0xb3, OP_NOP5: 0xb4, OP_NOP6: 0xb5, OP_NOP7: 0xb6, OP_NOP8: 0xb7, OP_NOP9: 0xb8, OP_NOP10: 0xb9, // Taproot OP_CHECKSIGADD: 0xba }; // Reverse mapping for disassembly const OPCODE_NAMES = Object.entries(OPCODES).reduce((acc, [name, code]) => { if (!acc[code]) acc[code] = name; return acc; }, {}); /** * Custom error class for script operations * @class ScriptError * @extends Error */ class ScriptError extends Error { constructor(message, code, details = {}) { super(message); this.name = 'ScriptError'; this.code = code; this.details = details; } } /** * Hash160 (SHA256 + RIPEMD160) * @param {Buffer} data - Data to hash * @returns {Buffer} 20-byte hash */ function hash160(data) { const sha = createHash('sha256').update(data).digest(); return rmd160(sha); } /** * Encode data as a push operation * @param {Buffer} data - Data to push * @returns {Buffer} Push operation bytes */ function encodePush(data) { if (!Buffer.isBuffer(data)) { data = Buffer.from(data, 'hex'); } const len = data.length; if (len === 0) { return Buffer.from([OPCODES.OP_0]); } if (len === 1 && data[0] >= 1 && data[0] <= 16) { return Buffer.from([OPCODES.OP_1 + data[0] - 1]); } if (len === 1 && data[0] === 0x81) { return Buffer.from([OPCODES.OP_1NEGATE]); } if (len < 0x4c) { return Buffer.concat([Buffer.from([len]), data]); } if (len <= 0xff) { return Buffer.concat([Buffer.from([OPCODES.OP_PUSHDATA1, len]), data]); } if (len <= 0xffff) { const lenBuf = Buffer.alloc(2); lenBuf.writeUInt16LE(len, 0); return Buffer.concat([Buffer.from([OPCODES.OP_PUSHDATA2]), lenBuf, data]); } const lenBuf = Buffer.alloc(4); lenBuf.writeUInt32LE(len, 0); return Buffer.concat([Buffer.from([OPCODES.OP_PUSHDATA4]), lenBuf, data]); } /** * Script Builder class * @class ScriptBuilder */ class ScriptBuilder { constructor() { this.chunks = []; } /** * Add an opcode * @param {number} opcode - Opcode to add * @returns {ScriptBuilder} this */ addOp(opcode) { this.chunks.push(Buffer.from([opcode])); return this; } /** * Push data onto the stack * @param {Buffer|string} data - Data to push * @returns {ScriptBuilder} this */ pushData(data) { const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'hex'); this.chunks.push(encodePush(buf)); return this; } /** * Push a number onto the stack * @param {number} num - Number to push * @returns {ScriptBuilder} this */ pushNumber(num) { if (num === 0) { return this.addOp(OPCODES.OP_0); } if (num === -1) { return this.addOp(OPCODES.OP_1NEGATE); } if (num >= 1 && num <= 16) { return this.addOp(OPCODES.OP_1 + num - 1); } // Encode as minimal push const negative = num < 0; let absNum = Math.abs(num); const bytes = []; while (absNum > 0) { bytes.push(absNum & 0xff); absNum >>= 8; } if (bytes[bytes.length - 1] & 0x80) { bytes.push(negative ? 0x80 : 0x00); } else if (negative) { bytes[bytes.length - 1] |= 0x80; } return this.pushData(Buffer.from(bytes)); } /** * Build the script * @returns {Buffer} Compiled script */ build() { return Buffer.concat(this.chunks); } /** * Reset the builder * @returns {ScriptBuilder} this */ reset() { this.chunks = []; return this; } // ===== Static factory methods ===== /** * Create P2PKH scriptPubKey * @param {Buffer} pubkeyHash - 20-byte public key hash * @returns {Buffer} scriptPubKey */ static createP2PKH(pubkeyHash) { if (pubkeyHash.length !== 20) { throw new ScriptError('P2PKH requires 20-byte hash', 'INVALID_HASH'); } // OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG return Buffer.concat([ Buffer.from([OPCODES.OP_DUP, OPCODES.OP_HASH160]), encodePush(pubkeyHash), Buffer.from([OPCODES.OP_EQUALVERIFY, OPCODES.OP_CHECKSIG]) ]); } /** * Create P2PKH scriptPubKey from public key * @param {Buffer} publicKey - Compressed or uncompressed public key * @returns {Buffer} scriptPubKey */ static createP2PKHFromPubkey(publicKey) { return this.createP2PKH(hash160(publicKey)); } /** * Create P2SH scriptPubKey * @param {Buffer} scriptHash - 20-byte script hash * @returns {Buffer} scriptPubKey */ static createP2SH(scriptHash) { if (scriptHash.length !== 20) { throw new ScriptError('P2SH requires 20-byte hash', 'INVALID_HASH'); } // OP_HASH160 <20 bytes> OP_EQUAL return Buffer.concat([ Buffer.from([OPCODES.OP_HASH160]), encodePush(scriptHash), Buffer.from([OPCODES.OP_EQUAL]) ]); } /** * Create P2WPKH scriptPubKey (SegWit v0) * @param {Buffer} pubkeyHash - 20-byte public key hash * @returns {Buffer} scriptPubKey */ static createP2WPKH(pubkeyHash) { if (pubkeyHash.length !== 20) { throw new ScriptError('P2WPKH requires 20-byte hash', 'INVALID_HASH'); } // OP_0 <20 bytes> return Buffer.concat([ Buffer.from([OPCODES.OP_0, 0x14]), pubkeyHash ]); } /** * Create P2WPKH scriptPubKey from public key * @param {Buffer} publicKey - Compressed public key * @returns {Buffer} scriptPubKey */ static createP2WPKHFromPubkey(publicKey) { return this.createP2WPKH(hash160(publicKey)); } /** * Create P2WSH scriptPubKey (SegWit v0) * @param {Buffer} scriptHash - 32-byte script hash (SHA256) * @returns {Buffer} scriptPubKey */ static createP2WSH(scriptHash) { if (scriptHash.length !== 32) { throw new ScriptError('P2WSH requires 32-byte hash', 'INVALID_HASH'); } // OP_0 <32 bytes> return Buffer.concat([ Buffer.from([OPCODES.OP_0, 0x20]), scriptHash ]); } /** * Create P2TR scriptPubKey (Taproot) * @param {Buffer} xOnlyPubkey - 32-byte x-only public key * @returns {Buffer} scriptPubKey */ static createP2TR(xOnlyPubkey) { if (xOnlyPubkey.length !== 32) { throw new ScriptError('P2TR requires 32-byte x-only pubkey', 'INVALID_PUBKEY'); } // OP_1 <32 bytes> return Buffer.concat([ Buffer.from([OPCODES.OP_1, 0x20]), xOnlyPubkey ]); } /** * Create OP_RETURN output script * @param {Buffer|string} data - Data to embed (max 80 bytes) * @returns {Buffer} scriptPubKey */ static createOpReturn(data) { const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8'); if (buf.length > 80) { throw new ScriptError('OP_RETURN data exceeds 80 bytes', 'DATA_TOO_LONG'); } return Buffer.concat([ Buffer.from([OPCODES.OP_RETURN]), encodePush(buf) ]); } /** * Create P2PKH scriptSig (unlocking script) * @param {Buffer} signature - DER signature with sighash type * @param {Buffer} publicKey - Public key * @returns {Buffer} scriptSig */ static createP2PKHScriptSig(signature, publicKey) { return Buffer.concat([ encodePush(signature), encodePush(publicKey) ]); } /** * Create P2SH scriptSig * @param {Array<Buffer>} pushData - Array of data to push * @param {Buffer} redeemScript - The redeem script * @returns {Buffer} scriptSig */ static createP2SHScriptSig(pushData, redeemScript) { const parts = pushData.map(d => encodePush(d)); parts.push(encodePush(redeemScript)); return Buffer.concat(parts); } /** * Create multisig script * @param {number} m - Required signatures * @param {Array<Buffer>} publicKeys - Public keys * @returns {Buffer} Multisig script */ static createMultisig(m, publicKeys) { if (m < 1 || m > publicKeys.length) { throw new ScriptError('Invalid M value for multisig', 'INVALID_M'); } if (publicKeys.length > 16) { throw new ScriptError('Too many keys for multisig', 'TOO_MANY_KEYS'); } const builder = new ScriptBuilder(); builder.pushNumber(m); for (const pk of publicKeys) { builder.pushData(pk); } builder.pushNumber(publicKeys.length); builder.addOp(OPCODES.OP_CHECKMULTISIG); return builder.build(); } /** * Create CLTV (CheckLockTimeVerify) script * @param {number} locktime - Locktime value * @param {Buffer} pubkeyHash - Public key hash * @returns {Buffer} CLTV script */ static createCLTV(locktime, pubkeyHash) { const builder = new ScriptBuilder(); builder.pushNumber(locktime); builder.addOp(OPCODES.OP_CHECKLOCKTIMEVERIFY); builder.addOp(OPCODES.OP_DROP); builder.addOp(OPCODES.OP_DUP); builder.addOp(OPCODES.OP_HASH160); builder.pushData(pubkeyHash); builder.addOp(OPCODES.OP_EQUALVERIFY); builder.addOp(OPCODES.OP_CHECKSIG); return builder.build(); } /** * Create CSV (CheckSequenceVerify) script * @param {number} sequence - Relative locktime * @param {Buffer} pubkeyHash - Public key hash * @returns {Buffer} CSV script */ static createCSV(sequence, pubkeyHash) { const builder = new ScriptBuilder(); builder.pushNumber(sequence); builder.addOp(OPCODES.OP_CHECKSEQUENCEVERIFY); builder.addOp(OPCODES.OP_DROP); builder.addOp(OPCODES.OP_DUP); builder.addOp(OPCODES.OP_HASH160); builder.pushData(pubkeyHash); builder.addOp(OPCODES.OP_EQUALVERIFY); builder.addOp(OPCODES.OP_CHECKSIG); return builder.build(); } /** * Parse a script into human-readable format * @param {Buffer} script - Script to parse * @returns {Array} Array of parsed elements */ static parse(script) { const result = []; let i = 0; while (i < script.length) { const opcode = script[i]; // Direct push (1-75 bytes) if (opcode >= 0x01 && opcode <= 0x4b) { const len = opcode; const data = script.slice(i + 1, i + 1 + len); result.push({ type: 'data', value: data, hex: data.toString('hex') }); i += 1 + len; continue; } // OP_PUSHDATA1 if (opcode === OPCODES.OP_PUSHDATA1) { const len = script[i + 1]; const data = script.slice(i + 2, i + 2 + len); result.push({ type: 'data', value: data, hex: data.toString('hex') }); i += 2 + len; continue; } // OP_PUSHDATA2 if (opcode === OPCODES.OP_PUSHDATA2) { const len = script.readUInt16LE(i + 1); const data = script.slice(i + 3, i + 3 + len); result.push({ type: 'data', value: data, hex: data.toString('hex') }); i += 3 + len; continue; } // OP_PUSHDATA4 if (opcode === OPCODES.OP_PUSHDATA4) { const len = script.readUInt32LE(i + 1); const data = script.slice(i + 5, i + 5 + len); result.push({ type: 'data', value: data, hex: data.toString('hex') }); i += 5 + len; continue; } // Regular opcode const name = OPCODE_NAMES[opcode] || `OP_UNKNOWN_${opcode.toString(16)}`; result.push({ type: 'opcode', value: opcode, name }); i += 1; } return result; } /** * Disassemble script to string * @param {Buffer} script - Script to disassemble * @returns {string} Human-readable script */ static disassemble(script) { const parsed = this.parse(script); return parsed.map(elem => { if (elem.type === 'data') { return `<${elem.hex}>`; } return elem.name; }).join(' '); } /** * Detect script type * @param {Buffer} script - Script to analyze * @returns {Object} Script type info */ static detectType(script) { const len = script.length; // P2PKH: OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG if (len === 25 && script[0] === 0x76 && script[1] === 0xa9 && script[2] === 0x14 && script[23] === 0x88 && script[24] === 0xac) { return { type: 'p2pkh', hash: script.slice(3, 23) }; } // P2SH: OP_HASH160 <20> OP_EQUAL if (len === 23 && script[0] === 0xa9 && script[1] === 0x14 && script[22] === 0x87) { return { type: 'p2sh', hash: script.slice(2, 22) }; } // P2WPKH: OP_0 <20> if (len === 22 && script[0] === 0x00 && script[1] === 0x14) { return { type: 'p2wpkh', program: script.slice(2) }; } // P2WSH: OP_0 <32> if (len === 34 && script[0] === 0x00 && script[1] === 0x20) { return { type: 'p2wsh', program: script.slice(2) }; } // P2TR: OP_1 <32> if (len === 34 && script[0] === 0x51 && script[1] === 0x20) { return { type: 'p2tr', program: script.slice(2) }; } // OP_RETURN if (script[0] === 0x6a) { return { type: 'op_return', data: script.slice(1) }; } // Multisig if (script[script.length - 1] === OPCODES.OP_CHECKMULTISIG) { const n = script[script.length - 2] - 0x50; const m = script[0] - 0x50; if (m >= 1 && m <= 16 && n >= 1 && n <= 16 && m <= n) { return { type: 'multisig', m, n }; } } return { type: 'unknown' }; } } export { OPCODES, OPCODE_NAMES, ScriptBuilder, ScriptError, encodePush, hash160 }; export default ScriptBuilder;