UNPKG

@bsv/sdk

Version:

BSV Blockchain Software Development Kit

456 lines (424 loc) 15.2 kB
import { Reader, Writer, toHex, toArray } from '../primitives/utils.js' import { hash256 } from '../primitives/Hash.js' import ChainTracker from './ChainTracker.js' export interface MerklePathLeaf { offset: number hash?: string txid?: boolean duplicate?: boolean } /** * 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. */ export default class MerklePath { blockHeight: number path: Array< Array<{ offset: number hash?: string txid?: boolean duplicate?: boolean }> > /** * 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: string): MerklePath { return MerklePath.fromBinary(toArray(hex, 'hex')) } static fromReader ( reader: Reader, legalOffsetsOnly: boolean = true ): MerklePath { 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<Array<{ offset: number, hash?: string, txid?: boolean, duplicate?: boolean }>> = Array(treeHeight) .fill(null) .map(() => []) let flags: number, offset: number, nLeavesAtThisHeight: number for (let level = 0; level < treeHeight; level++) { nLeavesAtThisHeight = reader.readVarIntNum() while (nLeavesAtThisHeight > 0) { offset = reader.readVarIntNum() flags = reader.readUInt8() const leaf: { offset: number hash?: string txid?: boolean duplicate?: boolean } = { offset } if ((flags & 1) !== 0) { leaf.duplicate = true } else { if ((flags & 2) !== 0) { leaf.txid = true } leaf.hash = 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: number[]): MerklePath { const reader = new 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: string, height: number): MerklePath { return new MerklePath(height, [[{ offset: 0, hash: txid, txid: true }]]) } constructor ( blockHeight: number, path: Array< Array<{ offset: number hash?: string txid?: boolean duplicate?: boolean }> >, legalOffsetsOnly: boolean = 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: string 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 (): number[] { const writer = new 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(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 (): string { return toHex(this.toBinary()) } // private indexOf (txid: string): number { 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?: string): string { 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: string): string => toHex(hash256(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: number, offset: number ): MerklePathLeaf | undefined { const hash = (m: string): string => toHex(hash256(toArray(m, 'hex').reverse()).reverse()) let leaf: MerklePathLeaf | undefined = 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: string 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: string, chainTracker: ChainTracker): Promise<boolean> { 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: MerklePath): void { 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: Array<Array<{ offset: number, hash?: string, txid?: boolean, duplicate?: boolean }>> = [] 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 (): void { const pushIfNew = (v: number, a: number[]): void => { if (a.length === 0 || a.slice(-1)[0] !== v) { a.push(v) } } const dropOffsetsFromLevel = (dropOffsets: number[], level: number): void => { 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: number[]): number[] => { const ncos: number[] = [] for (const o of cos) { pushIfNew(o >> 1, ncos) } return ncos } let computedOffsets: number[] = [] // in next level let dropOffsets: number[] = [] 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) } } }