@ethereumjs/binarytree
Version:
Implementation of binary trees as used in Ethereum.
603 lines • 29.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BinaryTree = void 0;
const util_1 = require("@ethereumjs/util");
const debug_1 = require("debug");
const index_ts_1 = require("./db/index.js");
const internalNode_ts_1 = require("./node/internalNode.js");
const stemNode_ts_1 = require("./node/stemNode.js");
const util_ts_1 = require("./node/util.js");
const types_ts_1 = require("./types.js");
/**
* The basic binary tree interface, use with `import { BinaryTree } from '@ethereumjs/binarytree'`.
*/
class BinaryTree {
/**
* Creates a new binary tree.
* @param opts Options for instantiating the binary tree
*
* Note: in most cases, the static {@link createBinaryTree} constructor should be used. It uses the same API but provides sensible defaults
*/
constructor(opts) {
this._lock = new util_1.Lock();
this._debug = (0, debug_1.default)('binarytree:#');
this._opts = opts;
if (opts.db instanceof index_ts_1.CheckpointDB) {
throw (0, util_1.EthereumJSErrorWithoutCode)('Cannot pass in an instance of CheckpointDB');
}
this._db = new index_ts_1.CheckpointDB({ db: opts.db, cacheSize: opts.cacheSize });
this.EMPTY_TREE_ROOT = new Uint8Array(32);
this._hashLen = 32;
this._root = this.EMPTY_TREE_ROOT;
if (opts?.root) {
this.root(opts.root);
}
this.DEBUG =
typeof window === 'undefined' ? (process?.env?.DEBUG?.includes('ethjs') ?? false) : false;
this.debug = this.DEBUG
? (message, namespaces = []) => {
let log = this._debug;
for (const name of namespaces) {
log = log.extend(name);
}
log(message);
}
: (..._) => { };
this.DEBUG &&
this.debug(`Trie created:
|| Root: ${(0, util_1.bytesToHex)(this._root)}
|| Persistent: ${this._opts.useRootPersistence}
|| CacheSize: ${this._opts.cacheSize}
|| ----------------`);
}
/**
* Gets and/or Sets the current root of the `tree`
*/
root(value) {
if (value !== undefined) {
if (value === null) {
value = this.EMPTY_TREE_ROOT;
}
if (value.length !== this._hashLen) {
throw (0, util_1.EthereumJSErrorWithoutCode)(`Invalid root length. Roots are ${this._hashLen} bytes`);
}
this._root = value;
}
return this._root;
}
/**
* Checks if a given root exists.
*/
async checkRoot(root) {
try {
const value = await this._db.get(root);
return value !== undefined;
}
catch (error) {
if (error.message === 'Missing node in DB') {
return (0, util_1.equalsBytes)(root, this.EMPTY_TREE_ROOT);
}
else {
throw error;
}
}
}
/**
* Gets values at a given binary tree `stem` and set of suffixes
* @param stem - the stem of the stem node where we're seeking values
* @param suffixes - an array of suffixes corresponding to the values desired
* @returns A Promise that resolves to an array of `Uint8Array`s or `null` depending on if values were found.
* If the stem is not found, will return an empty array.
*/
async get(stem, suffixes) {
if (stem.length !== 31)
throw (0, util_1.EthereumJSErrorWithoutCode)(`expected stem with length 31; got ${stem.length}`);
this.DEBUG && this.debug(`Stem: ${(0, util_1.bytesToHex)(stem)}; Suffix: ${suffixes}`, ['get']);
const stemPath = await this.findPath(stem);
if (stemPath.node instanceof stemNode_ts_1.StemBinaryNode) {
// The retrieved stem node contains an array of 256 possible values.
// We read all the suffixes to get the desired values
const values = [];
for (const suffix of suffixes) {
const value = stemPath.node.getValue(suffix);
this.DEBUG &&
this.debug(`Suffix: ${suffix}; Value: ${value === null ? 'null' : (0, util_1.bytesToHex)(value)}`, [
'get',
]);
values.push(value);
}
return values;
}
return [];
}
/**
* Stores a given `value` at the given `key` or performs a deletion if `value` is null.
* @param stem - the stem (must be 31 bytes) to store the value at.
* @param suffixes - array of suffixes at which to store individual values.
* @param values - the value(s) to store (or null for deletion).
* @returns A Promise that resolves once the value is stored.
*/
async put(stem, suffixes, values) {
if (stem.length !== 31)
throw (0, util_1.EthereumJSErrorWithoutCode)(`expected stem with length 31, got ${stem.length}`);
if (values.length > 0 && values.length !== suffixes.length)
throw (0, util_1.EthereumJSErrorWithoutCode)(`expected number of values (${values.length}) to equal number of suffixes (${suffixes.length})`);
this.DEBUG && this.debug(`Stem: ${(0, util_1.bytesToHex)(stem)}`, ['put']);
const putStack = []; // A stack of updated nodes starting with the stem node being updated/created to be saved to the DB
// If the tree is empty, initialize it.
if ((0, util_1.equalsBytes)(this.root(), this.EMPTY_TREE_ROOT)) {
await this._createInitialNode(stem, suffixes, values);
return;
}
// Find the path to the node (or the nearest node) for the given stem.
const foundPath = await this.findPath(stem);
// We should always at least get the root node back
if (foundPath.stack.length === 0)
throw (0, util_1.EthereumJSErrorWithoutCode)(`Root node not found in trie`);
// Step 1) Create or update the stem node
let stemNode;
// If we found a stem node with the same stem, we'll update it.
if (foundPath.node &&
(0, util_ts_1.isStemBinaryNode)(foundPath.node) &&
(0, util_1.equalsBytes)(foundPath.node.stem, stem)) {
stemNode = foundPath.node;
}
else {
// Otherwise, we'll create a new stem node.
stemNode = stemNode_ts_1.StemBinaryNode.create(stem);
this.DEBUG && this.debug(`Creating new stem node for stem: ${(0, util_1.bytesToHex)(stem)}`, ['put']);
}
// Update the values in the stem node
for (let i = 0; i < suffixes.length; i++) {
const suffix = suffixes[i];
const value = values[i];
stemNode.setValue(suffix, value);
this.DEBUG &&
this.debug(`Setting value for suffix: ${suffix} to value: ${value instanceof Uint8Array ? (0, util_1.bytesToHex)(value) : value} at stem node with stem: ${(0, util_1.bytesToHex)(stem)}`, ['put']);
}
// If all values are null then we treat this as a deletion.
if (stemNode.values.every((val) => val === null)) {
if (foundPath.node !== null) {
this.DEBUG && this.debug(`Deleting stem node for stem: ${(0, util_1.bytesToHex)(stem)}`, ['put']);
putStack.push([this.merkelize(stemNode), null]);
}
else {
return; // nothing to delete
}
}
else {
// Otherwise, we add the new or updated stemNode to the putStack
putStack.push([this.merkelize(stemNode), stemNode]);
}
// Get the bit representation of the stem.
const stemBits = (0, util_1.bytesToBits)(stemNode.stem);
// We keep a reference to the current "parent" node path as we update up the tree.
let lastUpdatedParentPath = [];
// Step 2: Add any needed new internal nodes if inserting a new stem.
// If updating an existing stem, just update the parent internal node reference
if (foundPath.stack.length > 1) {
// Pop the nearest node on the path.
const [nearestNode, nearestNodePath] = foundPath.stack.pop();
const parentPath = foundPath.stack[foundPath.stack.length - 1]?.[1] ?? [];
this.DEBUG && this.debug(`Adding necessary internal nodes.`, ['put']);
// Update the parent branch if necessary.
// If an update was necessary, updateBranch returns a stack of internal nodes
// that connect the new stem node to the previous parent inner node
const updated = this.updateBranch(stemNode, nearestNode, nearestNodePath, parentPath);
if (updated !== undefined) {
for (const update of updated) {
putStack.push([this.merkelize(update.node), update.node]);
lastUpdatedParentPath = update.parentPath;
}
}
}
// Step 3: Update remaining parent node hashes
while (foundPath.stack.length > 1) {
const [node, path] = foundPath.stack.pop();
if ((0, util_ts_1.isInternalBinaryNode)(node)) {
// Set child pointer to the last internal node in the putStack (last updated internal node)
node.setChild(lastUpdatedParentPath[lastUpdatedParentPath.length - 1], {
hash: putStack[putStack.length - 1][0], // Reuse hash already computed above
path: lastUpdatedParentPath,
});
putStack.push([this.merkelize(node), node]); // Update node hash and add to putStack
lastUpdatedParentPath = path;
this.DEBUG &&
this.debug(`Updated parent internal node hash for path ${path.join(',')}`, ['put']);
}
else {
throw (0, util_1.EthereumJSErrorWithoutCode)(`Expected internal node at path ${path.join(',')}, got ${node}`);
}
}
// Step 4: Update the root node.
let rootNode = foundPath.stack.pop()[0]; // The root node.
const childReference = putStack[putStack.length - 1][1];
if ((0, util_ts_1.isStemBinaryNode)(rootNode)) {
// If the root is a stem node but its stem differs from the one we're updating,
// then we need to split the root. Per the spec, when two stems share a common prefix,
// we create one internal node per bit in that common prefix, and then at the first
// divergence, an internal node that points to both stem nodes.
if (!(0, util_1.equalsBytes)(rootNode.stem, stem)) {
this.DEBUG && this.debug(`Root stem differs from new stem. Splitting root.`, ['put']);
const rootBits = (0, util_1.bytesToBits)(rootNode.stem);
const commonPrefixLength = (0, util_1.matchingBitsLength)(rootBits, stemBits);
// Create the split node at the divergence bit.
const splitNode = internalNode_ts_1.InternalBinaryNode.create();
const branchForNew = stemBits[commonPrefixLength];
const branchForExisting = rootBits[commonPrefixLength];
splitNode.setChild(branchForNew, {
hash: this.merkelize(stemNode),
path: stemBits,
});
splitNode.setChild(branchForExisting, {
hash: this.merkelize(rootNode),
path: rootBits,
});
let newRoot = splitNode;
// If there is a common prefix (i.e. commonPrefixLength > 0), we build a chain
// of internal nodes representing that prefix.
for (let depth = commonPrefixLength - 1; depth >= 0; depth--) {
this.DEBUG && this.debug(`Creating internal node at depth ${depth}`, ['put']);
putStack.push([this.merkelize(newRoot), newRoot]);
const parent = internalNode_ts_1.InternalBinaryNode.create();
// At each level, the branch is determined by the bit of the new stem at position i.
parent.setChild(stemBits[depth], {
hash: this.merkelize(newRoot),
path: stemBits.slice(0, depth + 1),
});
newRoot = parent;
}
// Now newRoot is an internal node chain that represents the entire common prefix,
// ending in a split node that distinguishes the two different stems.
rootNode = newRoot;
}
}
else {
// For an internal root node, we assign the last update child reference to the root.
if (childReference !== null) {
rootNode.setChild(stemBits[0], childReference !== null
? {
hash: this.merkelize(childReference),
path: (0, util_ts_1.isStemBinaryNode)(childReference) ? stemBits : lastUpdatedParentPath,
}
: null);
}
}
this.root(this.merkelize(rootNode));
putStack.push([this._root, rootNode]);
this.DEBUG && this.debug(`Updated root hash to ${(0, util_1.bytesToHex)(this._root)}`, ['put']);
await this.saveStack(putStack);
}
/**
* Helper method for updating or creating the parent internal node for a given stem node.
* If the nearest node is a stem node with a different stem, a new internal node is created
* to branch at the first differing bit.
* If the nearest node is an internal node, its child reference is updated.
*
* @param stemNode - The child stem node that will be referenced by the new/updated internal node.
* @param nearestNode - The nearest node to the new stem node.
* @param pathToNode - The path (in bits) to `nearestNode` as known from the trie.
* @returns An array of nodes and their partial paths from the new stem node to the branch parent node
* or `undefined` if no changes were made.
*/
updateBranch(stemNode, nearestNode, pathToNode, pathToParent) {
const stemBits = (0, util_1.bytesToBits)(stemNode.stem);
if ((0, util_ts_1.isStemBinaryNode)(nearestNode)) {
// For two different stems, find the first differing bit.
const nearestNodeStemBits = (0, util_1.bytesToBits)(nearestNode.stem);
const diffIndex = (0, util_1.matchingBitsLength)(stemBits, nearestNodeStemBits);
const parentDiffIndex = (0, util_1.matchingBitsLength)(pathToNode, pathToParent);
const newInternal = internalNode_ts_1.InternalBinaryNode.create();
// Set the child pointer for the new stem node using the bit at diffIndex.
newInternal.setChild(stemBits[diffIndex], {
hash: this.merkelize(stemNode),
path: stemBits,
});
// Set the child pointer for the existing stem node.
newInternal.setChild(nearestNodeStemBits[diffIndex], {
hash: this.merkelize(nearestNode),
path: nearestNodeStemBits,
});
const putStack = [{ node: newInternal, parentPath: stemBits.slice(0, diffIndex) }];
let parent = newInternal;
for (let depth = diffIndex - 1; depth > parentDiffIndex; depth--) {
this.DEBUG && this.debug(`Creating internal node at depth ${depth}`, ['put']);
const newParent = internalNode_ts_1.InternalBinaryNode.create();
// At each level, the branch is determined by the bit of the new stem at position i.
newParent.setChild(stemBits[depth], {
hash: this.merkelize(parent),
path: stemBits.slice(0, depth + 1),
});
putStack.push({ node: newParent, parentPath: stemBits.slice(0, depth) });
parent = newParent;
}
// Return the stack of new internal nodes that connect the new stem node to the previous parent inner node
return putStack;
}
else if ((0, util_ts_1.isInternalBinaryNode)(nearestNode)) {
// For an internal node, determine the branch index using the parent's known path length.
const branchIndex = stemBits[pathToNode.length];
nearestNode.setChild(branchIndex, {
hash: this.merkelize(stemNode),
path: stemBits,
});
return [{ node: nearestNode, parentPath: pathToNode }];
}
return undefined;
}
/**
* Tries to find a path to the node for the given key.
* It returns a `Path` object containing:
* - `node`: the found node (if any),
* - `stack`: an array of tuples [node, path] representing the nodes encountered,
* - `remaining`: the bits of the key that were not matched.
*
* @param keyInBytes - the search key as a byte array.
* @returns A Promise that resolves to a Path object.
*/
async findPath(keyInBytes) {
const keyInBits = (0, util_1.bytesToBits)(keyInBytes);
this.DEBUG && this.debug(`Searching for key: ${(0, util_1.bytesToHex)(keyInBytes)}`, ['find_path']);
const result = {
node: null,
stack: [],
remaining: keyInBits,
};
// If tree is empty, return empty path.
if ((0, util_1.equalsBytes)(this.root(), this.EMPTY_TREE_ROOT))
return result;
// Get the root node.
let rawNode = await this._db.get(this.root());
if (rawNode === undefined)
throw (0, util_1.EthereumJSErrorWithoutCode)('root node should exist');
const rootNode = (0, util_ts_1.decodeBinaryNode)(rawNode);
this.DEBUG && this.debug(`Starting with Root Node: [${(0, util_1.bytesToHex)(this.root())}]`, ['find_path']);
// Treat the root as being at an empty path.
result.stack.push([rootNode, []]);
// If the root node is a stem node, we're done.
if ((0, util_ts_1.isStemBinaryNode)(rootNode)) {
this.DEBUG && this.debug(`Found stem node at root.`, ['find_path']);
if ((0, util_1.equalsBytes)(keyInBytes, rootNode.stem)) {
result.node = rootNode;
result.remaining = [];
}
return result;
}
// The root is an internal node. Determine the branch to follow using the first bit of the key
let childNode = rootNode.getChild(keyInBits[0]);
let finished = false;
while (!finished) {
if (childNode === null)
break;
// Look up child node by its node hash.
rawNode = await this._db.get(childNode.hash);
if (rawNode === undefined)
throw (0, util_1.EthereumJSErrorWithoutCode)(`missing node at ${childNode.path}`);
const decodedNode = (0, util_ts_1.decodeBinaryNode)(rawNode);
// Determine how many bits match between keyInBits and the stored path in childNode.
const matchingKeyLength = (0, util_1.matchingBitsLength)(keyInBits, childNode.path);
// If we have an exact match (i.e. the stored path equals a prefix of the key)
// and either the key is fully consumed or we have reached a stem node, we stop.
if (matchingKeyLength === childNode.path.length &&
(matchingKeyLength === keyInBits.length || (0, util_ts_1.isStemBinaryNode)(decodedNode))) {
finished = true;
if (matchingKeyLength === keyInBits.length &&
(0, util_1.equalsBits)(keyInBits, childNode.path) === true) {
// We found the sought node
this.DEBUG &&
this.debug(`Path ${(0, util_1.bytesToHex)(keyInBytes)} - found full path to node ${(0, util_1.bytesToHex)(this.merkelize(decodedNode))}.`, ['find_path']);
result.node = decodedNode;
result.remaining = [];
return result;
}
// We didn't find the sought node so record the unmatched tail of the key.
result.remaining = keyInBits.slice(matchingKeyLength);
result.stack.push([decodedNode, childNode.path]);
return result;
}
// Otherwise, push this internal node and continue.
result.stack.push([decodedNode, keyInBits.slice(0, matchingKeyLength)]);
this.DEBUG &&
this.debug(`Partial Path ${keyInBits.slice(0, matchingKeyLength)} - found next node in path ${(0, util_1.bytesToHex)(this.merkelize(decodedNode))}.`, ['find_path']);
// If the decoded node is not internal, then we cannot traverse further.
if (!(0, util_ts_1.isInternalBinaryNode)(decodedNode)) {
result.remaining = keyInBits.slice(matchingKeyLength);
finished = true;
break;
}
// The next branch is determined by the next bit after the matched prefix.
const childIndex = keyInBits[matchingKeyLength];
childNode = decodedNode.getChild(childIndex);
if (childNode === null) {
result.remaining = keyInBits.slice(matchingKeyLength);
finished = true;
}
}
this.DEBUG &&
this.debug(`Found partial path ${(0, util_1.bytesToHex)((0, util_1.bitsToBytes)(keyInBits.slice(256 - result.remaining.length)))} but sought node is not present in trie.`, ['find_path']);
return result;
}
/**
* Deletes a given `key` from the tree.
* @param stem - the stem of the stem node to delete from
* @param suffixes - the suffixes to delete
* @returns A Promise that resolves once the key is deleted.
*/
async del(stem, suffixes) {
this.DEBUG && this.debug(`Stem: ${(0, util_1.bytesToHex)(stem)}; Suffix(es): ${suffixes}`, ['del']);
await this.put(stem, suffixes, new Array(suffixes.length).fill(null));
}
/**
* Create empty root node for initializing an empty tree.
*/
async createRootNode() {
const rootNode = null;
this.DEBUG && this.debug(`No root node. Creating new root node`, ['initialize']);
this.root(this.merkelize(rootNode));
await this.saveStack([[this.root(), rootNode]]);
return;
}
/**
* Creates the initial node from an empty tree.
* @private
*/
async _createInitialNode(stem, indexes, values) {
const initialNode = stemNode_ts_1.StemBinaryNode.create(stem);
for (let i = 0; i < indexes.length; i++) {
initialNode.setValue(indexes[i], values[i]);
}
this.root(this.merkelize(initialNode));
await this._db.put(this.root(), initialNode.serialize());
await this.persistRoot();
}
/**
* Saves a stack of nodes to the database.
*
* @param putStack - an array of tuples of keys (the partial path of the node in the trie) and nodes (BinaryNodes)
*/
async saveStack(putStack) {
const opStack = putStack.map(([key, node]) => {
return {
type: node !== null ? 'put' : 'del',
key,
value: node !== null ? node.serialize() : null,
};
});
await this._db.batch(opStack);
}
/**
* Creates a proof from a tree and key that can be verified using {@link BinaryTree.verifyBinaryProof}.
* @param key a 32 byte binary tree key (31 byte stem + 1 byte suffix)
*/
async createBinaryProof(key) {
this.DEBUG && this.debug(`creating proof for ${(0, util_1.bytesToHex)(key)}`, ['create_proof']);
// We only use the stem (i.e. the first 31 bytes) to find the path to the node
const { node, stack } = await this.findPath(key.slice(0, 31));
const proof = stack.map(([node, _]) => node.serialize());
if (node !== null) {
// If node is found, add node to proof
proof.push(node.serialize());
}
return proof;
}
/**
* The `data` event is given an `Object` that has two properties; the `key` and the `value`. Both should be Uint8Arrays.
* @return Returns a [stream](https://nodejs.org/dist/latest-v12.x/docs/api/stream.html#stream_class_stream_readable) of the contents of the `tree`
*/
createReadStream() {
throw (0, util_1.EthereumJSErrorWithoutCode)('Not implemented');
}
/**
* Returns a copy of the underlying tree.
*
* Note on db: the copy will create a reference to the
* same underlying database.
*
* Note on cache: for memory reasons a copy will not
* recreate a new LRU cache but initialize with cache
* being deactivated.
*
* @param includeCheckpoints - If true and during a checkpoint, the copy will contain the checkpointing metadata and will use the same scratch as underlying db.
*/
shallowCopy(includeCheckpoints = true) {
const tree = new BinaryTree({
...this._opts,
db: this._db.db.shallowCopy(),
root: this.root(),
cacheSize: 0,
});
if (includeCheckpoints && this.hasCheckpoints()) {
tree._db.setCheckpoints(this._db.checkpoints);
}
return tree;
}
/**
* Persists the root hash in the underlying database
*/
async persistRoot() {
if (this._opts.useRootPersistence === true) {
await this._db.put(types_ts_1.ROOT_DB_KEY, this.root());
}
}
/**
* Is the tree during a checkpoint phase?
*/
hasCheckpoints() {
return this._db.hasCheckpoints();
}
/**
* Creates a checkpoint that can later be reverted to or committed.
* After this is called, all changes can be reverted until `commit` is called.
*/
checkpoint() {
this._db.checkpoint(this.root());
}
/**
* Commits a checkpoint to disk, if current checkpoint is not nested.
* If nested, only sets the parent checkpoint as current checkpoint.
* @throws If not during a checkpoint phase
*/
async commit() {
if (!this.hasCheckpoints()) {
throw (0, util_1.EthereumJSErrorWithoutCode)('trying to commit when not checkpointed');
}
await this._lock.acquire();
await this._db.commit();
await this.persistRoot();
this._lock.release();
}
/**
* Reverts the tree to the state it was at when `checkpoint` was first called.
* If during a nested checkpoint, sets root to most recent checkpoint, and sets
* parent checkpoint as current.
*/
async revert() {
if (!this.hasCheckpoints()) {
throw (0, util_1.EthereumJSErrorWithoutCode)('trying to revert when not checkpointed');
}
await this._lock.acquire();
this.root(await this._db.revert());
await this.persistRoot();
this._lock.release();
}
/**
* Flushes all checkpoints, restoring the initial checkpoint state.
*/
flushCheckpoints() {
this._db.checkpoints = [];
}
hash(msg) {
// As per spec, if value is null or a 64-byte array of 0s, hash(msg) is a 32-byte array of 0s
if (msg === null || (msg.length === 64 && msg.every((byte) => byte === 0))) {
return new Uint8Array(32);
}
if (msg.length !== 32 && msg.length !== 64) {
throw (0, util_1.EthereumJSErrorWithoutCode)('Data must be 32 or 64 bytes');
}
return Uint8Array.from(this._opts.hashFunction.call(undefined, msg));
}
merkelize(node) {
if (node === null) {
return new Uint8Array(32);
}
if ((0, util_ts_1.isInternalBinaryNode)(node)) {
const [leftChild, rightChild] = node.children;
return this.hash((0, util_1.concatBytes)(leftChild === null ? this.hash(null) : leftChild.hash, rightChild === null ? this.hash(null) : rightChild.hash));
}
// Otherwise, it's a stem node.
// Map each value in node.values through the hash function.
let currentLayerHashes = node.values.map((value) => this.hash(value));
// While there is more than one hash at the current layer, combine them pairwise.
while (currentLayerHashes.length > 1) {
const newLayerHashes = [];
for (let i = 0; i < currentLayerHashes.length; i += 2) {
newLayerHashes.push(this.hash((0, util_1.concatBytes)(currentLayerHashes[i], currentLayerHashes[i + 1])));
}
currentLayerHashes = newLayerHashes;
}
// Return the hash of the concatenation of node.stem appended with 00 and the final level hash.
return this.hash((0, util_1.concatBytes)((0, util_1.setLengthRight)(node.stem, 32), currentLayerHashes[0]));
}
}
exports.BinaryTree = BinaryTree;
//# sourceMappingURL=binaryTree.js.map