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