src/transaction/parser.js

/**
 * @fileoverview Transaction Parser
 * @description Parse raw Bitcoin transaction hex back into structured objects
 * @version 1.0.0
 * @author yfbsei
 * @license ISC
 */

/**
 * Custom error for transaction parsing
 * @class TransactionParseError
 * @extends Error
 */
class TransactionParseError extends Error {
    constructor(message, code, details = {}) {
        super(message);
        this.name = 'TransactionParseError';
        this.code = code;
        this.details = details;
    }
}

/**
 * Transaction Parser class
 * @class TransactionParser
 */
class TransactionParser {
    /**
     * Parse transaction from hex string
     * @param {string} hex - Raw transaction hex
     * @returns {Object} Parsed transaction object
     */
    static fromHex(hex) {
        const buffer = Buffer.from(hex, 'hex');
        return this.fromBuffer(buffer);
    }

    /**
     * Parse transaction from buffer
     * @param {Buffer} buffer - Raw transaction bytes
     * @returns {Object} Parsed transaction object
     */
    static fromBuffer(buffer) {
        let offset = 0;

        // Version (4 bytes, LE)
        const version = buffer.readInt32LE(offset);
        offset += 4;

        // Check for witness marker
        let hasWitness = false;
        if (buffer[offset] === 0x00 && buffer[offset + 1] === 0x01) {
            hasWitness = true;
            offset += 2;
        }

        // Input count
        const inputCountResult = this._readVarInt(buffer, offset);
        const inputCount = inputCountResult.value;
        offset = inputCountResult.offset;

        // Parse inputs
        const inputs = [];
        for (let i = 0; i < inputCount; i++) {
            const inputResult = this._parseInput(buffer, offset);
            inputs.push(inputResult.input);
            offset = inputResult.offset;
        }

        // Output count
        const outputCountResult = this._readVarInt(buffer, offset);
        const outputCount = outputCountResult.value;
        offset = outputCountResult.offset;

        // Parse outputs
        const outputs = [];
        for (let i = 0; i < outputCount; i++) {
            const outputResult = this._parseOutput(buffer, offset);
            outputs.push(outputResult.output);
            offset = outputResult.offset;
        }

        // Parse witness data if present
        const witnesses = [];
        if (hasWitness) {
            for (let i = 0; i < inputCount; i++) {
                const witnessResult = this._parseWitness(buffer, offset);
                witnesses.push(witnessResult.items);
                offset = witnessResult.offset;
            }
        }

        // Locktime (4 bytes, LE)
        const locktime = buffer.readUInt32LE(offset);
        offset += 4;

        return {
            version,
            inputs,
            outputs,
            witnesses: hasWitness ? witnesses : [],
            locktime,
            hasWitness
        };
    }

    /**
     * Parse a single input
     * @private
     */
    static _parseInput(buffer, offset) {
        // txid (32 bytes, reversed)
        const txidBytes = buffer.slice(offset, offset + 32);
        const txid = Buffer.from(txidBytes).reverse().toString('hex');
        offset += 32;

        // vout (4 bytes, LE)
        const vout = buffer.readUInt32LE(offset);
        offset += 4;

        // scriptSig length
        const scriptLenResult = this._readVarInt(buffer, offset);
        const scriptLen = scriptLenResult.value;
        offset = scriptLenResult.offset;

        // scriptSig
        const scriptSig = buffer.slice(offset, offset + scriptLen);
        offset += scriptLen;

        // sequence (4 bytes, LE)
        const sequence = buffer.readUInt32LE(offset);
        offset += 4;

        return {
            input: { txid, vout, scriptSig, sequence },
            offset
        };
    }

    /**
     * Parse a single output
     * @private
     */
    static _parseOutput(buffer, offset) {
        // value (8 bytes, LE)
        const value = Number(buffer.readBigUInt64LE(offset));
        offset += 8;

        // scriptPubKey length
        const scriptLenResult = this._readVarInt(buffer, offset);
        const scriptLen = scriptLenResult.value;
        offset = scriptLenResult.offset;

        // scriptPubKey
        const scriptPubKey = buffer.slice(offset, offset + scriptLen);
        offset += scriptLen;

        return {
            output: { value, scriptPubKey },
            offset
        };
    }

    /**
     * Parse witness data
     * @private
     */
    static _parseWitness(buffer, offset) {
        const countResult = this._readVarInt(buffer, offset);
        const count = countResult.value;
        offset = countResult.offset;

        const items = [];
        for (let i = 0; i < count; i++) {
            const lenResult = this._readVarInt(buffer, offset);
            const len = lenResult.value;
            offset = lenResult.offset;

            items.push(buffer.slice(offset, offset + len));
            offset += len;
        }

        return { items, offset };
    }

    /**
     * Read variable-length integer
     * @private
     */
    static _readVarInt(buffer, offset) {
        const first = buffer[offset];

        if (first < 0xfd) {
            return { value: first, offset: offset + 1 };
        } else if (first === 0xfd) {
            return { value: buffer.readUInt16LE(offset + 1), offset: offset + 3 };
        } else if (first === 0xfe) {
            return { value: buffer.readUInt32LE(offset + 1), offset: offset + 5 };
        } else {
            return { value: Number(buffer.readBigUInt64LE(offset + 1)), offset: offset + 9 };
        }
    }

    /**
     * Get transaction ID from raw hex
     * @param {string} hex - Raw transaction hex
     * @returns {string} Transaction ID
     */
    static getTxid(hex) {
        const { createHash } = require('node:crypto');
        const buffer = Buffer.from(hex, 'hex');

        // Remove witness data for txid calculation
        const tx = this.fromBuffer(buffer);
        const noWitnessHex = this.serializeWithoutWitness(tx);

        const hash = createHash('sha256')
            .update(createHash('sha256').update(noWitnessHex).digest())
            .digest();

        return hash.reverse().toString('hex');
    }

    /**
     * Serialize transaction without witness (for txid)
     * @param {Object} tx - Parsed transaction
     * @returns {Buffer} Serialized bytes
     */
    static serializeWithoutWitness(tx) {
        const parts = [];

        // Version
        const version = Buffer.alloc(4);
        version.writeInt32LE(tx.version, 0);
        parts.push(version);

        // Inputs
        parts.push(this._encodeVarInt(tx.inputs.length));
        for (const input of tx.inputs) {
            parts.push(Buffer.from(input.txid, 'hex').reverse());
            const vout = Buffer.alloc(4);
            vout.writeUInt32LE(input.vout, 0);
            parts.push(vout);
            const scriptSig = Buffer.isBuffer(input.scriptSig) ? input.scriptSig : Buffer.from(input.scriptSig, 'hex');
            parts.push(this._encodeVarInt(scriptSig.length));
            parts.push(scriptSig);
            const seq = Buffer.alloc(4);
            seq.writeUInt32LE(input.sequence, 0);
            parts.push(seq);
        }

        // Outputs
        parts.push(this._encodeVarInt(tx.outputs.length));
        for (const output of tx.outputs) {
            const value = Buffer.alloc(8);
            value.writeBigUInt64LE(BigInt(output.value), 0);
            parts.push(value);
            const script = Buffer.isBuffer(output.scriptPubKey) ? output.scriptPubKey : Buffer.from(output.scriptPubKey, 'hex');
            parts.push(this._encodeVarInt(script.length));
            parts.push(script);
        }

        // Locktime
        const locktime = Buffer.alloc(4);
        locktime.writeUInt32LE(tx.locktime, 0);
        parts.push(locktime);

        return Buffer.concat(parts);
    }

    /**
     * Encode variable-length integer
     * @private
     */
    static _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;
        }
    }
}

export { TransactionParser, TransactionParseError };
export default TransactionParser;