@bsv/sdk
Version:
BSV Blockchain Software Development Kit
383 lines • 16.1 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
const utils_js_1 = require("../primitives/utils.js");
const Hash_js_1 = require("../primitives/Hash.js");
/**
* Represents a Merkle Path, which is used to provide a compact proof of inclusion for a
* transaction in a block. This class encapsulates all the details required for creating
* and verifying Merkle Proofs.
*
* @class MerklePath
* @property {number} blockHeight - The height of the block in which the transaction is included.
* @property {Array<Array<{offset: number, hash?: string, txid?: boolean, duplicate?: boolean}>>} path -
* A tree structure representing the Merkle Path, with each level containing information
* about the nodes involved in constructing the proof.
*
* @example
* // Creating and verifying a Merkle Path
* const merklePath = MerklePath.fromHex('...');
* const isValid = merklePath.verify(txid, chainTracker);
*
* @description
* The MerklePath class is useful for verifying transactions in a lightweight and efficient manner without
* needing the entire block data. This class offers functionalities for creating, converting,
* and verifying these proofs.
*/
class MerklePath {
/**
* Creates a MerklePath instance from a hexadecimal string.
*
* @static
* @param {string} hex - The hexadecimal string representation of the Merkle Path.
* @returns {MerklePath} - A new MerklePath instance.
*/
static fromHex(hex) {
return MerklePath.fromBinary((0, utils_js_1.toArray)(hex, 'hex'));
}
static fromReader(reader, legalOffsetsOnly = true) {
const blockHeight = reader.readVarIntNum();
const treeHeight = reader.readUInt8();
// Explicitly define the type of path as an array of arrays of leaf objects
const path = Array(treeHeight)
.fill(null)
.map(() => []);
let flags, offset, nLeavesAtThisHeight;
for (let level = 0; level < treeHeight; level++) {
nLeavesAtThisHeight = reader.readVarIntNum();
while (nLeavesAtThisHeight > 0) {
offset = reader.readVarIntNum();
flags = reader.readUInt8();
const leaf = { offset };
if ((flags & 1) !== 0) {
leaf.duplicate = true;
}
else {
if ((flags & 2) !== 0) {
leaf.txid = true;
}
leaf.hash = (0, utils_js_1.toHex)(reader.read(32).reverse());
}
// Ensure path[level] exists before pushing
if (!Array.isArray(path[level]) || path[level].length === 0) {
path[level] = [];
}
path[level].push(leaf);
nLeavesAtThisHeight--;
}
// Sort the array based on the offset property
path[level].sort((a, b) => a.offset - b.offset);
}
return new MerklePath(blockHeight, path, legalOffsetsOnly);
}
/**
* Creates a MerklePath instance from a binary array.
*
* @static
* @param {number[]} bump - The binary array representation of the Merkle Path.
* @returns {MerklePath} - A new MerklePath instance.
*/
static fromBinary(bump) {
const reader = new utils_js_1.Reader(bump);
return MerklePath.fromReader(reader);
}
/**
*
* @static fromCoinbaseTxid
*
* Creates a MerklePath instance for a coinbase transaction in an empty block.
* This edge case is difficult to retrieve from standard APIs.
*
* @param {string} txid - The coinbase txid.
* @param {number} height - The height of the block.
* @returns {MerklePath} - A new MerklePath instance which assumes the tx is in a block with no other transactions.
*/
static fromCoinbaseTxidAndHeight(txid, height) {
return new MerklePath(height, [[{ offset: 0, hash: txid, txid: true }]]);
}
constructor(blockHeight, path, legalOffsetsOnly = true) {
this.blockHeight = blockHeight;
this.path = path;
// store all of the legal offsets which we expect given the txid indices.
const legalOffsets = Array(this.path.length)
.fill(0)
.map(() => new Set());
this.path.forEach((leaves, height) => {
if (leaves.length === 0 && height === 0) {
throw new Error(`Empty level at height: ${height}`);
}
const offsetsAtThisHeight = new Set();
leaves.forEach((leaf) => {
if (offsetsAtThisHeight.has(leaf.offset)) {
throw new Error(`Duplicate offset: ${leaf.offset}, at height: ${height}`);
}
offsetsAtThisHeight.add(leaf.offset);
if (height === 0) {
if (leaf.duplicate !== true) {
for (let h = 1; h < this.path.length; h++) {
legalOffsets[h].add((leaf.offset >> h) ^ 1);
}
}
}
else {
if (legalOffsetsOnly && !legalOffsets[height].has(leaf.offset)) {
throw new Error(`Invalid offset: ${leaf.offset}, at height: ${height}, with legal offsets: ${Array.from(legalOffsets[height]).join(', ')}`);
}
}
});
});
// every txid must calculate to the same root.
let root;
this.path[0].forEach((leaf, idx) => {
if (idx === 0)
root = this.computeRoot(leaf.hash);
if (root !== this.computeRoot(leaf.hash)) {
throw new Error('Mismatched roots');
}
});
}
/**
* Converts the MerklePath to a binary array format.
*
* @returns {number[]} - The binary array representation of the Merkle Path.
*/
toBinary() {
const writer = new utils_js_1.Writer();
writer.writeVarIntNum(this.blockHeight);
const treeHeight = this.path.length;
writer.writeUInt8(treeHeight);
for (let level = 0; level < treeHeight; level++) {
const nLeaves = Object.keys(this.path[level]).length;
writer.writeVarIntNum(nLeaves);
for (const leaf of this.path[level]) {
writer.writeVarIntNum(leaf.offset);
let flags = 0;
if (leaf?.duplicate === true) {
flags |= 1;
}
if (leaf?.txid !== undefined && leaf.txid !== null) {
flags |= 2;
}
writer.writeUInt8(flags);
if ((flags & 1) === 0) {
writer.write((0, utils_js_1.toArray)(leaf.hash, 'hex').reverse());
}
}
}
return writer.toArray();
}
/**
* Converts the MerklePath to a hexadecimal string format.
*
* @returns {string} - The hexadecimal string representation of the Merkle Path.
*/
toHex() {
return (0, utils_js_1.toHex)(this.toBinary());
}
//
indexOf(txid) {
const leaf = this.path[0].find((l) => l.hash === txid);
if (leaf === null || leaf === undefined) {
throw new Error(`Transaction ID ${txid} not found in the Merkle Path`);
}
return leaf.offset;
}
/**
* Computes the Merkle root from the provided transaction ID.
*
* @param {string} txid - The transaction ID to compute the Merkle root for. If not provided, the root will be computed from an unspecified branch, and not all branches will be validated!
* @returns {string} - The computed Merkle root as a hexadecimal string.
* @throws {Error} - If the transaction ID is not part of the Merkle Path.
*/
computeRoot(txid) {
if (typeof txid !== 'string') {
const foundLeaf = this.path[0].find((leaf) => Boolean(leaf?.hash));
if (foundLeaf === null || foundLeaf === undefined) {
throw new Error('No valid leaf found in the Merkle Path');
}
txid = foundLeaf.hash;
}
// Find the index of the txid at the lowest level of the Merkle tree
if (typeof txid !== 'string') {
throw new Error('Transaction ID is undefined');
}
const index = this.indexOf(txid);
if (typeof index !== 'number') {
throw new Error(`This proof does not contain the txid: ${txid ?? 'undefined'}`);
}
// Calculate the root using the index as a way to determine which direction to concatenate.
const hash = (m) => (0, utils_js_1.toHex)((0, Hash_js_1.hash256)((0, utils_js_1.toArray)(m, 'hex').reverse()).reverse());
let workingHash = txid;
// special case for blocks with only one transaction
if (this.path.length === 1 && this.path[0].length === 1)
return workingHash;
for (let height = 0; height < this.path.length; height++) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const leaves = this.path[height];
const offset = (index >> height) ^ 1;
const leaf = this.findOrComputeLeaf(height, offset);
if (typeof leaf !== 'object') {
throw new Error(`Missing hash for index ${index} at height ${height}`);
}
if (leaf.duplicate === true) {
workingHash = hash((workingHash ?? '') + (workingHash ?? ''));
}
else if (offset % 2 !== 0) {
workingHash = hash((leaf.hash ?? '') + (workingHash ?? ''));
}
else {
workingHash = hash((workingHash ?? '') + (leaf.hash ?? ''));
}
}
return workingHash;
}
/**
* Find leaf with `offset` at `height` or compute from level below, recursively.
*
* Does not add computed leaves to path.
*
* @param height
* @param offset
*/
findOrComputeLeaf(height, offset) {
const hash = (m) => (0, utils_js_1.toHex)((0, Hash_js_1.hash256)((0, utils_js_1.toArray)(m, 'hex').reverse()).reverse());
let leaf = this.path[height].find((l) => l.offset === offset);
if (leaf != null)
return leaf;
if (height === 0)
return undefined;
const h = height - 1;
const l = offset << 1;
const leaf0 = this.findOrComputeLeaf(h, l);
if (leaf0 == null || leaf0.hash == null || leaf0.hash === '')
return undefined;
const leaf1 = this.findOrComputeLeaf(h, l + 1);
if (leaf1 == null)
return undefined;
let workinghash;
if (leaf1.duplicate === true) {
workinghash = hash(leaf0.hash + leaf0.hash);
}
else {
workinghash = hash((leaf1.hash ?? '') + (leaf0.hash ?? ''));
}
leaf = {
offset,
hash: workinghash
};
return leaf;
}
/**
* Verifies if the given transaction ID is part of the Merkle tree at the specified block height.
*
* @param {string} txid - The transaction ID to verify.
* @param {ChainTracker} chainTracker - The ChainTracker instance used to verify the Merkle root.
* @returns {boolean} - True if the transaction ID is valid within the Merkle Path at the specified block height.
*/
async verify(txid, chainTracker) {
const root = this.computeRoot(txid);
if (this.indexOf(txid) === 0) {
// Coinbase transaction outputs can only be spent once they're 100 blocks deep.
const height = await chainTracker.currentHeight();
if (this.blockHeight + 100 < height) {
return false;
}
}
// Use the chain tracker to determine whether this is a valid merkle root at the given block height
return await chainTracker.isValidRootForHeight(root, this.blockHeight);
}
/**
* Combines this MerklePath with another to create a compound proof.
*
* @param {MerklePath} other - Another MerklePath to combine with this path.
* @throws {Error} - If the paths have different block heights or roots.
*/
combine(other) {
if (this.blockHeight !== other.blockHeight) {
throw new Error('You cannot combine paths which do not have the same block height.');
}
const root1 = this.computeRoot();
const root2 = other.computeRoot();
if (root1 !== root2) {
throw new Error('You cannot combine paths which do not have the same root.');
}
const combinedPath = [];
for (let h = 0; h < this.path.length; h++) {
combinedPath.push([]);
for (let l = 0; l < this.path[h].length; l++) {
combinedPath[h].push(this.path[h][l]);
}
for (let l = 0; l < other.path[h].length; l++) {
if (combinedPath[h].find((leaf) => leaf.offset === other.path[h][l].offset) === undefined) {
combinedPath[h].push(other.path[h][l]);
}
else {
// Ensure that any elements which appear in both are not downgraded to a non txid.
if (other.path[h][l]?.txid !== undefined && other.path[h][l]?.txid !== null) {
const target = combinedPath[h].find((leaf) => leaf.offset === other.path[h][l].offset);
if (target !== null && target !== undefined) {
target.txid = true;
}
}
}
}
}
this.path = combinedPath;
this.trim();
}
/**
* Remove all internal nodes that are not required by level zero txid nodes.
* Assumes that at least all required nodes are present.
* Leaves all levels sorted by increasing offset.
*/
trim() {
const pushIfNew = (v, a) => {
if (a.length === 0 || a.slice(-1)[0] !== v) {
a.push(v);
}
};
const dropOffsetsFromLevel = (dropOffsets, level) => {
for (let i = dropOffsets.length; i >= 0; i--) {
const l = this.path[level].findIndex((n) => n.offset === dropOffsets[i]);
if (l >= 0) {
this.path[level].splice(l, 1);
}
}
};
const nextComputedOffsets = (cos) => {
const ncos = [];
for (const o of cos) {
pushIfNew(o >> 1, ncos);
}
return ncos;
};
let computedOffsets = []; // in next level
let dropOffsets = [];
for (let h = 0; h < this.path.length; h++) {
// Sort each level by increasing offset order
this.path[h].sort((a, b) => a.offset - b.offset);
}
for (let l = 0; l < this.path[0].length; l++) {
const n = this.path[0][l];
if (n.txid === true) {
// level 0 must enable computing level 1 for txid nodes
pushIfNew(n.offset >> 1, computedOffsets);
}
else {
const isOdd = n.offset % 2 === 1;
const peer = this.path[0][l + (isOdd ? -1 : 1)];
if (peer.txid === undefined || peer.txid === null || !peer.txid) {
// drop non-txid level 0 nodes without a txid peer
pushIfNew(peer.offset, dropOffsets);
}
}
}
dropOffsetsFromLevel(dropOffsets, 0);
for (let h = 1; h < this.path.length; h++) {
dropOffsets = computedOffsets;
computedOffsets = nextComputedOffsets(computedOffsets);
dropOffsetsFromLevel(dropOffsets, h);
}
}
}
exports.default = MerklePath;
//# sourceMappingURL=MerklePath.js.map