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

756 lines (634 loc) 20.5 kB
/** * @fileoverview PSBT (Partially Signed Bitcoin Transaction) implementation * @version 1.0.0 * @author yfbsei * @license ISC */ import { createHash } from 'node:crypto'; class PSBTError extends Error { constructor(message, code, details = {}) { super(message); this.name = 'PSBTError'; this.code = code; this.details = details; } } const PSBT_CONSTANTS = { MAGIC: Buffer.from([0x70, 0x73, 0x62, 0x74, 0xff]), GLOBAL_UNSIGNED_TX: 0x00, GLOBAL_XPUB: 0x01, IN_NON_WITNESS_UTXO: 0x00, IN_WITNESS_UTXO: 0x01, IN_PARTIAL_SIG: 0x02, IN_SIGHASH_TYPE: 0x03, IN_REDEEM_SCRIPT: 0x04, IN_WITNESS_SCRIPT: 0x05, IN_FINAL_SCRIPTSIG: 0x07, IN_FINAL_SCRIPTWITNESS: 0x08, IN_TAP_KEY_SIG: 0x13, IN_TAP_INTERNAL_KEY: 0x17, OUT_REDEEM_SCRIPT: 0x00, OUT_WITNESS_SCRIPT: 0x01, OUT_TAP_INTERNAL_KEY: 0x05, OUT_TAP_TREE: 0x06 }; class PSBTKeyValue { constructor(keyType, keyData, value) { this.keyType = keyType; this.keyData = keyData; this.value = value; } serialize() { const keyLen = 1 + this.keyData.length; const parts = [ this._encodeVarInt(keyLen), Buffer.from([this.keyType]), this.keyData, this._encodeVarInt(this.value.length), this.value ]; return Buffer.concat(parts); } _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; } } } class PSBT { constructor() { this.global = { unsignedTx: null, xpubs: new Map() }; this.inputs = []; this.outputs = []; } /** * Parse PSBT from raw buffer * @param {Buffer} data - Raw PSBT bytes * @returns {PSBT} Parsed PSBT */ static fromBuffer(data) { if (!Buffer.isBuffer(data)) { data = Buffer.from(data); } // Check magic bytes const magic = data.slice(0, 5); if (!magic.equals(PSBT_CONSTANTS.MAGIC)) { throw new PSBTError('Invalid PSBT magic bytes', 'INVALID_MAGIC'); } const psbt = new PSBT(); let offset = 5; // Parse global map const globalResult = PSBT._parseMap(data, offset); offset = globalResult.offset; for (const [key, value] of globalResult.entries) { const keyType = key[0]; const keyData = key.slice(1); if (keyType === PSBT_CONSTANTS.GLOBAL_UNSIGNED_TX) { psbt.global.unsignedTx = PSBT._parseUnsignedTx(value); } else if (keyType === PSBT_CONSTANTS.GLOBAL_XPUB) { psbt.global.xpubs.set(keyData.toString('hex'), value); } } if (!psbt.global.unsignedTx) { throw new PSBTError('PSBT missing unsigned transaction', 'MISSING_TX'); } const inputCount = psbt.global.unsignedTx.inputs.length; const outputCount = psbt.global.unsignedTx.outputs.length; // Parse input maps for (let i = 0; i < inputCount; i++) { const inputResult = PSBT._parseMap(data, offset); offset = inputResult.offset; const input = { nonWitnessUtxo: null, witnessUtxo: null, partialSigs: new Map(), sighashType: null, redeemScript: null, witnessScript: null, finalScriptSig: null, finalScriptWitness: null, tapKeySig: null, tapInternalKey: null }; for (const [key, value] of inputResult.entries) { const keyType = key[0]; const keyData = key.slice(1); switch (keyType) { case PSBT_CONSTANTS.IN_NON_WITNESS_UTXO: input.nonWitnessUtxo = value; break; case PSBT_CONSTANTS.IN_WITNESS_UTXO: input.witnessUtxo = PSBT._parseWitnessUtxo(value); break; case PSBT_CONSTANTS.IN_PARTIAL_SIG: input.partialSigs.set(keyData.toString('hex'), value); break; case PSBT_CONSTANTS.IN_SIGHASH_TYPE: input.sighashType = value.readUInt32LE(0); break; case PSBT_CONSTANTS.IN_REDEEM_SCRIPT: input.redeemScript = value; break; case PSBT_CONSTANTS.IN_WITNESS_SCRIPT: input.witnessScript = value; break; case PSBT_CONSTANTS.IN_FINAL_SCRIPTSIG: input.finalScriptSig = value; break; case PSBT_CONSTANTS.IN_FINAL_SCRIPTWITNESS: input.finalScriptWitness = PSBT._parseWitness(value); break; case PSBT_CONSTANTS.IN_TAP_KEY_SIG: input.tapKeySig = value; break; case PSBT_CONSTANTS.IN_TAP_INTERNAL_KEY: input.tapInternalKey = value; break; } } psbt.inputs.push(input); } // Parse output maps for (let i = 0; i < outputCount; i++) { const outputResult = PSBT._parseMap(data, offset); offset = outputResult.offset; const output = { redeemScript: null, witnessScript: null, tapInternalKey: null, tapTree: null }; for (const [key, value] of outputResult.entries) { const keyType = key[0]; switch (keyType) { case PSBT_CONSTANTS.OUT_REDEEM_SCRIPT: output.redeemScript = value; break; case PSBT_CONSTANTS.OUT_WITNESS_SCRIPT: output.witnessScript = value; break; case PSBT_CONSTANTS.OUT_TAP_INTERNAL_KEY: output.tapInternalKey = value; break; case PSBT_CONSTANTS.OUT_TAP_TREE: output.tapTree = value; break; } } psbt.outputs.push(output); } return psbt; } /** * Parse PSBT from base64 string * @param {string} base64 - Base64-encoded PSBT * @returns {PSBT} Parsed PSBT */ static fromBase64(base64) { const data = Buffer.from(base64, 'base64'); return PSBT.fromBuffer(data); } /** * Parse PSBT from hex string * @param {string} hex - Hex-encoded PSBT * @returns {PSBT} Parsed PSBT */ static fromHex(hex) { const data = Buffer.from(hex, 'hex'); return PSBT.fromBuffer(data); } /** * Parse a key-value map from PSBT data * @private */ static _parseMap(data, offset) { const entries = []; while (offset < data.length) { // Read key length const keyLenResult = PSBT._readVarInt(data, offset); const keyLen = keyLenResult.value; offset = keyLenResult.offset; // Separator if (keyLen === 0) { break; } // Read key const key = data.slice(offset, offset + keyLen); offset += keyLen; // Read value length const valueLenResult = PSBT._readVarInt(data, offset); const valueLen = valueLenResult.value; offset = valueLenResult.offset; // Read value const value = data.slice(offset, offset + valueLen); offset += valueLen; entries.push([key, value]); } return { entries, offset }; } /** * Read variable-length integer * @private */ static _readVarInt(data, offset) { const first = data[offset]; if (first < 0xfd) { return { value: first, offset: offset + 1 }; } else if (first === 0xfd) { return { value: data.readUInt16LE(offset + 1), offset: offset + 3 }; } else if (first === 0xfe) { return { value: data.readUInt32LE(offset + 1), offset: offset + 5 }; } else { return { value: Number(data.readBigUInt64LE(offset + 1)), offset: offset + 9 }; } } /** * Parse unsigned transaction from PSBT * @private */ static _parseUnsignedTx(data) { let offset = 0; const version = data.readUInt32LE(offset); offset += 4; // Input count const inputCountResult = PSBT._readVarInt(data, offset); const inputCount = inputCountResult.value; offset = inputCountResult.offset; const inputs = []; for (let i = 0; i < inputCount; i++) { const hash = data.slice(offset, offset + 32); offset += 32; const index = data.readUInt32LE(offset); offset += 4; // scriptSig length (should be 0) const scriptLenResult = PSBT._readVarInt(data, offset); offset = scriptLenResult.offset + scriptLenResult.value; const sequence = data.readUInt32LE(offset); offset += 4; inputs.push({ hash, index, sequence }); } // Output count const outputCountResult = PSBT._readVarInt(data, offset); const outputCount = outputCountResult.value; offset = outputCountResult.offset; const outputs = []; for (let i = 0; i < outputCount; i++) { const amount = Number(data.readBigUInt64LE(offset)); offset += 8; const scriptLenResult = PSBT._readVarInt(data, offset); const scriptLen = scriptLenResult.value; offset = scriptLenResult.offset; const script = data.slice(offset, offset + scriptLen); offset += scriptLen; outputs.push({ amount, script }); } const locktime = data.readUInt32LE(offset); return { version, inputs, outputs, locktime }; } /** * Parse witness UTXO * @private */ static _parseWitnessUtxo(data) { let offset = 0; const amount = Number(data.readBigUInt64LE(offset)); offset += 8; const scriptLenResult = PSBT._readVarInt(data, offset); const scriptLen = scriptLenResult.value; offset = scriptLenResult.offset; const scriptPubKey = data.slice(offset, offset + scriptLen); return { amount, scriptPubKey }; } /** * Parse witness stack * @private */ static _parseWitness(data) { const items = []; let offset = 0; const countResult = PSBT._readVarInt(data, offset); const count = countResult.value; offset = countResult.offset; for (let i = 0; i < count; i++) { const lenResult = PSBT._readVarInt(data, offset); const len = lenResult.value; offset = lenResult.offset; items.push(data.slice(offset, offset + len)); offset += len; } return items; } /** * Export PSBT as base64 string * @returns {string} Base64-encoded PSBT */ toBase64() { return this.serialize().toString('base64'); } /** * Export PSBT as hex string * @returns {string} Hex-encoded PSBT */ toHex() { return this.serialize().toString('hex'); } static fromTransaction(transaction) { const psbt = new PSBT(); psbt.global.unsignedTx = { version: transaction.version, inputs: transaction.inputs.map(input => ({ hash: Buffer.from(input.txid, 'hex').reverse(), index: input.vout, sequence: input.sequence || 0xffffffff })), outputs: transaction.outputs.map(output => ({ amount: output.value, script: output.scriptPubKey })), locktime: transaction.locktime || 0 }; for (let i = 0; i < transaction.inputs.length; i++) { psbt.inputs.push({ nonWitnessUtxo: null, witnessUtxo: null, partialSigs: new Map(), sighashType: null, redeemScript: null, witnessScript: null, finalScriptSig: null, finalScriptWitness: null, tapKeySig: null, tapInternalKey: null }); } for (let i = 0; i < transaction.outputs.length; i++) { psbt.outputs.push({ redeemScript: null, witnessScript: null, tapInternalKey: null, tapTree: null }); } return psbt; } addInput(inputData) { this.inputs.push({ nonWitnessUtxo: inputData.nonWitnessUtxo || null, witnessUtxo: inputData.witnessUtxo || null, partialSigs: new Map(), sighashType: inputData.sighashType || null, redeemScript: inputData.redeemScript || null, witnessScript: inputData.witnessScript || null, finalScriptSig: null, finalScriptWitness: null, tapKeySig: inputData.tapKeySig || null, tapInternalKey: inputData.tapInternalKey || null }); return this.inputs.length - 1; } addOutput(outputData) { this.outputs.push({ redeemScript: outputData.redeemScript || null, witnessScript: outputData.witnessScript || null, tapInternalKey: outputData.tapInternalKey || null, tapTree: outputData.tapTree || null }); return this.outputs.length - 1; } setWitnessUtxo(inputIndex, witnessUtxo) { if (inputIndex < 0 || inputIndex >= this.inputs.length) { throw new PSBTError('Invalid input index', 'INVALID_INDEX'); } this.inputs[inputIndex].witnessUtxo = witnessUtxo; return this; } addPartialSignature(inputIndex, pubkey, signature) { if (inputIndex < 0 || inputIndex >= this.inputs.length) { throw new PSBTError('Invalid input index', 'INVALID_INDEX'); } const pubkeyHex = Buffer.isBuffer(pubkey) ? pubkey.toString('hex') : pubkey; this.inputs[inputIndex].partialSigs.set(pubkeyHex, signature); return this; } setTapKeySig(inputIndex, signature) { if (inputIndex < 0 || inputIndex >= this.inputs.length) { throw new PSBTError('Invalid input index', 'INVALID_INDEX'); } this.inputs[inputIndex].tapKeySig = signature; return this; } finalizeInput(inputIndex) { if (inputIndex < 0 || inputIndex >= this.inputs.length) { throw new PSBTError('Invalid input index', 'INVALID_INDEX'); } const input = this.inputs[inputIndex]; if (input.tapKeySig) { input.finalScriptWitness = [input.tapKeySig]; return this; } if (input.partialSigs.size > 0) { const [pubkey, sig] = [...input.partialSigs.entries()][0]; input.finalScriptWitness = [ Buffer.from(sig), Buffer.from(pubkey, 'hex') ]; return this; } throw new PSBTError('Cannot finalize input without signatures', 'NO_SIGNATURES'); } finalizeAllInputs() { for (let i = 0; i < this.inputs.length; i++) { this.finalizeInput(i); } return this; } isFinalized() { return this.inputs.every(input => input.finalScriptSig !== null || input.finalScriptWitness !== null ); } extractTransaction() { if (!this.isFinalized()) { throw new PSBTError('PSBT not finalized', 'NOT_FINALIZED'); } return { version: this.global.unsignedTx.version, inputs: this.global.unsignedTx.inputs.map((input, i) => ({ txid: input.hash.reverse().toString('hex'), vout: input.index, sequence: input.sequence, scriptSig: this.inputs[i].finalScriptSig || Buffer.alloc(0) })), outputs: this.global.unsignedTx.outputs.map(output => ({ value: output.amount, scriptPubKey: output.script })), locktime: this.global.unsignedTx.locktime, witnesses: this.inputs.map(input => input.finalScriptWitness || []) }; } serialize() { const parts = [PSBT_CONSTANTS.MAGIC]; parts.push(this._serializeGlobal()); for (let i = 0; i < this.inputs.length; i++) { parts.push(this._serializeInput(i)); } for (let i = 0; i < this.outputs.length; i++) { parts.push(this._serializeOutput(i)); } return Buffer.concat(parts); } _serializeGlobal() { const fields = []; if (this.global.unsignedTx) { const txData = this._serializeUnsignedTransaction(); fields.push(new PSBTKeyValue( PSBT_CONSTANTS.GLOBAL_UNSIGNED_TX, Buffer.alloc(0), txData )); } const serializedFields = fields.map(field => field.serialize()); serializedFields.push(Buffer.from([0x00])); return Buffer.concat(serializedFields); } _serializeInput(index) { const input = this.inputs[index]; const fields = []; if (input.witnessUtxo) { fields.push(new PSBTKeyValue( PSBT_CONSTANTS.IN_WITNESS_UTXO, Buffer.alloc(0), this._serializeWitnessUtxo(input.witnessUtxo) )); } for (const [pubkey, sig] of input.partialSigs) { fields.push(new PSBTKeyValue( PSBT_CONSTANTS.IN_PARTIAL_SIG, Buffer.from(pubkey, 'hex'), sig )); } if (input.tapKeySig) { fields.push(new PSBTKeyValue( PSBT_CONSTANTS.IN_TAP_KEY_SIG, Buffer.alloc(0), input.tapKeySig )); } if (input.finalScriptWitness) { fields.push(new PSBTKeyValue( PSBT_CONSTANTS.IN_FINAL_SCRIPTWITNESS, Buffer.alloc(0), this._serializeWitness(input.finalScriptWitness) )); } const serializedFields = fields.map(field => field.serialize()); serializedFields.push(Buffer.from([0x00])); return Buffer.concat(serializedFields); } _serializeOutput(index) { const output = this.outputs[index]; const fields = []; if (output.tapInternalKey) { fields.push(new PSBTKeyValue( PSBT_CONSTANTS.OUT_TAP_INTERNAL_KEY, Buffer.alloc(0), output.tapInternalKey )); } const serializedFields = fields.map(field => field.serialize()); serializedFields.push(Buffer.from([0x00])); return Buffer.concat(serializedFields); } _serializeUnsignedTransaction() { const parts = []; const tx = this.global.unsignedTx; const version = Buffer.alloc(4); version.writeUInt32LE(tx.version, 0); parts.push(version); parts.push(this._encodeVarInt(tx.inputs.length)); for (const input of tx.inputs) { parts.push(input.hash); const index = Buffer.alloc(4); index.writeUInt32LE(input.index, 0); parts.push(index); parts.push(Buffer.from([0x00])); const sequence = Buffer.alloc(4); sequence.writeUInt32LE(input.sequence, 0); parts.push(sequence); } parts.push(this._encodeVarInt(tx.outputs.length)); for (const output of tx.outputs) { const amount = Buffer.alloc(8); amount.writeBigUInt64LE(BigInt(output.amount), 0); parts.push(amount); parts.push(this._encodeVarInt(output.script.length)); parts.push(output.script); } const locktime = Buffer.alloc(4); locktime.writeUInt32LE(tx.locktime, 0); parts.push(locktime); return Buffer.concat(parts); } _serializeWitnessUtxo(utxo) { const amount = Buffer.alloc(8); amount.writeBigUInt64LE(BigInt(utxo.amount), 0); const scriptLen = this._encodeVarInt(utxo.scriptPubKey.length); return Buffer.concat([amount, scriptLen, utxo.scriptPubKey]); } _serializeWitness(witness) { const parts = [this._encodeVarInt(witness.length)]; for (const item of witness) { parts.push(this._encodeVarInt(item.length)); parts.push(item); } return Buffer.concat(parts); } _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() { const cloned = new PSBT(); cloned.global = JSON.parse(JSON.stringify(this.global)); cloned.inputs = this.inputs.map(input => ({ ...input })); cloned.outputs = this.outputs.map(output => ({ ...output })); return cloned; } } export { PSBT, PSBTError, PSBTKeyValue, PSBT_CONSTANTS };