@zk-kit/sparse-merkle-tree
Version:
Sparse Merkle tree implementation in TypeScript.
396 lines (338 loc) • 15.2 kB
text/typescript
import { checkHex, getFirstCommonElements, getIndexOfLastNonZeroElement, keyToPath } from "./utils"
import { ChildNodes, EntryMark, HashFunction, Key, Value, Node, Siblings, EntryResponse, MerkleProof } from "./types"
/**
* SparseMerkleTree class provides all the functions to create a sparse Merkle tree
* and to take advantage of its features: {@linkcode SparseMerkleTree.add}, {@linkcode SparseMerkleTree.get},
* {@linkcode SparseMerkleTree.update}, {@linkcode SparseMerkleTree.delete}, {@linkcode SparseMerkleTree.createProof},
* {@linkcode SparseMerkleTree.verifyProof}.
* To better understand the code below it may be useful to describe the terminology used:
* * **nodes**: every node in the tree is the hash of the two child nodes (`H(x, y)`);
* * **root node**: the root node is the top hash and since it represents the whole data
* structure it can be used to certify its integrity;
* * **leaf nodes**: every leaf node is the hash of a key/value pair and an additional
* value to mark the node as leaf node (`H(x, y, 1)`);
* * **entry**: a tree entry is a key/value pair used to create the leaf nodes;
* * **zero nodes**: a zero node is an hash of zeros and in this implementation `H(0,0) = 0`;
* * **siblings**: the children of a parent node are siblings;
* * **path**: every entry key is a number < 2^256 that can be converted in a binary number,
* and this binary number is the path used to place the entry in the tree (1 or 0 define the
* child node to choose);
* * **matching node**: when an entry is not found and the path leads to another existing entry,
* this entry is a matching entry and it has some of the first bits in common with the entry not found;
* * **depth**: the depth of a node is the length of the path to its root.
*/
export default class SparseMerkleTree {
// Hash function used to hash the child nodes.
private hash: HashFunction
// Value for zero nodes.
private zeroNode: Node
// Additional entry value to mark the leaf nodes.
private entryMark: EntryMark
// If true it sets `BigInt` type as default type of the tree hashes.
private bigNumbers: boolean
// Key/value map in which the key is a node of the tree and
// the value is an array of child nodes. When the node is
// a leaf node the child nodes are an entry (key/value) of the tree.
private nodes: Map<Node, ChildNodes>
// The root node of the tree.
root: Node
/**
* Initializes the SparseMerkleTree attributes.
* @param hash Hash function used to hash the child nodes.
* @param bigNumbers BigInt type enabling.
*/
constructor(hash: HashFunction, bigNumbers = false) {
if (bigNumbers) {
/* istanbul ignore next */
if (typeof BigInt !== "function") {
throw new Error("Big numbers are not supported")
}
if (typeof hash([BigInt(1), BigInt(1)]) !== "bigint") {
throw new Error("The hash function must return a big number")
}
} else if (!checkHex(hash(["1", "1"]) as string)) {
throw new Error("The hash function must return a hexadecimal")
}
this.hash = hash
this.bigNumbers = bigNumbers
this.zeroNode = bigNumbers ? BigInt(0) : "0"
this.entryMark = bigNumbers ? BigInt(1) : "1"
this.nodes = new Map()
this.root = this.zeroNode // The root node is initially a zero node.
}
/**
* Gets a key and if the key exists in the tree the function returns the
* value, otherwise it returns 'undefined'.
* @param key A key of a tree entry.
* @returns A value of a tree entry or 'undefined'.
*/
get(key: Key): Value | undefined {
this.checkParameterType(key)
const { entry } = this.retrieveEntry(key)
return entry[1]
}
/**
* Adds a new entry in the tree. It retrieves a matching entry
* or a zero node with a top-down approach and then it updates all the
* hashes of the nodes in the path of the new entry with a bottom-up approach.
* @param key The key of the new entry.
* @param value The value of the new entry.
*/
add(key: Key, value: Value) {
this.checkParameterType(key)
this.checkParameterType(value)
const { entry, matchingEntry, siblings } = this.retrieveEntry(key)
if (entry[1] !== undefined) {
throw new Error(`Key "${key}" already exists`)
}
const path = keyToPath(key)
// If there is a matching entry its node is saved, otherwise
// the node is a zero node. This node is used below as the first node
// (starting from the bottom of the tree) to obtain the new nodes
// up to the root.
const node = matchingEntry ? this.hash(matchingEntry) : this.zeroNode
// If there are siblings it deletes all the nodes of the path.
// These nodes will be re-created below with the new hashes.
if (siblings.length > 0) {
this.deleteOldNodes(node, path, siblings)
}
// If there is a matching entry, further N zero siblings are added
// in the `siblings` array, followed by the matching node itself.
// N is the number of the first matching bits of the paths.
// This is helpful in the non-membership proof verification
// as explained in the function below.
if (matchingEntry) {
const matchingPath = keyToPath(matchingEntry[0])
for (let i = siblings.length; matchingPath[i] === path[i]; i += 1) {
siblings.push(this.zeroNode)
}
siblings.push(node)
}
// Adds the new entry and re-creates the nodes of the path with the new hashes
// with a bottom-up approach. The `addNewNodes` function returns the last node
// added, which is the root node.
const newNode = this.hash([key, value, this.entryMark])
this.nodes.set(newNode, [key, value, this.entryMark])
this.root = this.addNewNodes(newNode, path, siblings)
}
/**
* Updates a value of an entry in the tree. Also in this case
* all the hashes of the nodes in the path of the entry are updated
* with a bottom-up approach.
* @param key The key of the entry.
* @param value The value of the entry.
*/
update(key: Key, value: Value) {
this.checkParameterType(key)
this.checkParameterType(value)
const { entry, siblings } = this.retrieveEntry(key)
if (entry[1] === undefined) {
throw new Error(`Key "${key}" does not exist`)
}
const path = keyToPath(key)
// Deletes the old entry and all the nodes in its path.
const oldNode = this.hash(entry)
this.nodes.delete(oldNode)
this.deleteOldNodes(oldNode, path, siblings)
// Adds the new entry and re-creates the nodes of the path
// with the new hashes.
const newNode = this.hash([key, value, this.entryMark])
this.nodes.set(newNode, [key, value, this.entryMark])
this.root = this.addNewNodes(newNode, path, siblings)
}
/**
* Deletes an entry in the tree. Also in this case all the hashes of
* the nodes in the path of the entry are updated with a bottom-up approach.
* @param key The key of the entry.
*/
delete(key: Key) {
this.checkParameterType(key)
const { entry, siblings } = this.retrieveEntry(key)
if (entry[1] === undefined) {
throw new Error(`Key "${key}" does not exist`)
}
const path = keyToPath(key)
// Deletes the entry.
const node = this.hash(entry)
this.nodes.delete(node)
this.root = this.zeroNode
// If there are siblings it deletes the nodes of the path and
// re-creates them with the new hashes.
if (siblings.length > 0) {
this.deleteOldNodes(node, path, siblings)
// If the last siblings is not a leaf node, it adds all the
// nodes of the path starting from a zero node, otherwise
// it removes the last non-zero siblings from the `siblings`
// array and it starts from it by skipping the last zero nodes.
if (!this.isLeaf(siblings[siblings.length - 1])) {
this.root = this.addNewNodes(this.zeroNode, path, siblings)
} else {
const firstSibling = siblings.pop() as Node
const i = getIndexOfLastNonZeroElement(siblings)
this.root = this.addNewNodes(firstSibling, path, siblings, i)
}
}
}
/**
* Creates a proof to prove the membership or the non-membership
* of a tree entry.
* @param key A key of an existing or a non-existing entry.
* @returns The membership or the non-membership proof.
*/
createProof(key: Key): MerkleProof {
this.checkParameterType(key)
const { entry, matchingEntry, siblings } = this.retrieveEntry(key)
// If the key exists the function returns a membership proof, otherwise it
// returns a non-membership proof with the matching entry.
return {
entry,
matchingEntry,
siblings,
root: this.root,
membership: !!entry[1]
}
}
/**
* Verifies a membership or a non-membership proof.
* @param merkleProof The proof to verify.
* @returns True if the proof is valid, false otherwise.
*/
verifyProof(merkleProof: MerkleProof): boolean {
// If there is not a matching entry it simply obtains the root
// hash by using the siblings and the path of the key.
if (!merkleProof.matchingEntry) {
const path = keyToPath(merkleProof.entry[0])
// If there is not an entry value the proof is a non-membership proof,
// and in this case, since there is not a matching entry, the node
// is a zero node. If there is an entry value the proof is a
// membership proof and the node is the hash of the entry.
const node = merkleProof.entry[1] !== undefined ? this.hash(merkleProof.entry) : this.zeroNode
const root = this.calculateRoot(node, path, merkleProof.siblings)
// If the obtained root is equal to the proof root, then the proof is valid.
return root === merkleProof.root
}
// If there is a matching entry the proof is definitely a non-membership
// proof. In this case it checks if the matching node belongs to the tree
// and then it checks if the number of the first matching bits of the keys
// is greater than or equal to the number of the siblings.
const matchingPath = keyToPath(merkleProof.matchingEntry[0])
const node = this.hash(merkleProof.matchingEntry)
const root = this.calculateRoot(node, matchingPath, merkleProof.siblings)
if (root === merkleProof.root) {
const path = keyToPath(merkleProof.entry[0])
// Returns the first common bits of the two keys: the
// non-member key and the matching key.
const firstMatchingBits = getFirstCommonElements(path, matchingPath)
// If the non-member key was a key of a tree entry, the depth of the
// matching node should be greater than the number of the first common
// bits of the keys. The depth of a node can be defined by the number
// of its siblings.
return merkleProof.siblings.length <= firstMatchingBits.length
}
return false
}
/**
* Searches for an entry in the tree. If the key passed as parameter exists in
* the tree, the function returns the entry, otherwise it returns the entry
* with only the key, and when there is another existing entry
* in the same path it returns also this entry as 'matching entry'.
* In any case the function returns the siblings of the path.
* @param key The key of the entry to search for.
* @returns The entry response.
*/
private retrieveEntry(key: Key): EntryResponse {
const path = keyToPath(key)
const siblings: Siblings = []
// Starts from the root and goes down into the tree until it finds
// the entry, a zero node or a matching entry.
for (let i = 0, node = this.root; node !== this.zeroNode; i += 1) {
const childNodes = this.nodes.get(node) as ChildNodes
const direction = path[i]
// If the third position of the array is not empty the child nodes
// are an entry of the tree.
if (childNodes[2]) {
if (childNodes[0] === key) {
// An entry with the same key was found and
// it returns it with the siblings.
return { entry: childNodes, siblings }
}
// The entry found does not have the same key. But the key of this
// particular entry matches the first 'i' bits of the key passed
// as parameter and it can be useful in several functions.
return { entry: [key], matchingEntry: childNodes, siblings }
}
// When it goes down into the tree and follows the path, in every step
// a node is chosen between the left and the right child nodes, and the
// opposite node is saved as siblings.
node = childNodes[direction] as Node
siblings.push(childNodes[Number(!direction)] as Node)
}
// The path led to a zero node.
return { entry: [key], siblings }
}
/**
* Calculates nodes with a bottom-up approach until it reaches the root node.
* @param node The node to start from.
* @param path The path of the key.
* @param siblings The siblings of the path.
* @returns The root node.
*/
private calculateRoot(node: Node, path: number[], siblings: Siblings): Node {
for (let i = siblings.length - 1; i >= 0; i -= 1) {
const childNodes: ChildNodes = path[i] ? [siblings[i], node] : [node, siblings[i]]
node = this.hash(childNodes)
}
return node
}
/**
* Adds new nodes in the tree with a bottom-up approach until it reaches the root node.
* @param node The node to start from.
* @param path The path of the key.
* @param siblings The siblings of the path.
* @param i The index to start from.
* @returns The root node.
*/
private addNewNodes(node: Node, path: number[], siblings: Siblings, i = siblings.length - 1): Node {
for (; i >= 0; i -= 1) {
const childNodes: ChildNodes = path[i] ? [siblings[i], node] : [node, siblings[i]]
node = this.hash(childNodes)
this.nodes.set(node, childNodes)
}
return node
}
/**
* Deletes nodes in the tree with a bottom-up approach until it reaches the root node.
* @param node The node to start from.
* @param path The path of the key.
* @param siblings The siblings of the path.
* @param i The index to start from.
*/
private deleteOldNodes(node: Node, path: number[], siblings: Siblings) {
for (let i = siblings.length - 1; i >= 0; i -= 1) {
const childNodes: ChildNodes = path[i] ? [siblings[i], node] : [node, siblings[i]]
node = this.hash(childNodes)
this.nodes.delete(node)
}
}
/**
* Checks if a node is a leaf node.
* @param node A node of the tree.
* @returns True if the node is a leaf, false otherwise.
*/
private isLeaf(node: Node): boolean {
const childNodes = this.nodes.get(node)
return !!(childNodes && childNodes[2])
}
/**
* Checks the parameter type.
* @param parameter The parameter to check.
*/
private checkParameterType(parameter: Key | Value) {
if (this.bigNumbers && typeof parameter !== "bigint") {
throw new Error(`Parameter ${parameter} must be a big number`)
}
if (!this.bigNumbers && !checkHex(parameter as string)) {
throw new Error(`Parameter ${parameter} must be a hexadecimal`)
}
}
}