diff --git a/common-files/classes/timber.mjs b/common-files/classes/timber.mjs index 5662dd375..4ec96c081 100644 --- a/common-files/classes/timber.mjs +++ b/common-files/classes/timber.mjs @@ -56,6 +56,36 @@ const _insertLeaf = (leafVal, tree, path) => { } }; +/** +This function is like _insertLeaf but it doesnt prune children on insertion +@function _safeInsertLeaf +@param {string} leafVal - The commitment hash to be inserted into the tree +@param {object} tree - The tree where leafVal will be inserted +@param {string} path - The path down tree that leafVal will be inserted into +@returns {object} An updated tree post-insertion but deepest branches are preserved. +*/ +const _safeInsertLeaf = (leafVal, tree, path) => { + if (path.length === 0) { + if (tree.value === '0') return Leaf(leafVal); + return tree; + } + switch (tree.tag) { + // If we are at a branch, we use the next element in path to decide if we go down the left or right subtree. + case 'branch': + return path[0] === '0' + ? Branch(_safeInsertLeaf(leafVal, tree.left, path.slice(1)), tree.right) + : Branch(tree.left, _safeInsertLeaf(leafVal, tree.right, path.slice(1))); + // If we are at a leaf AND path.length > 0, we need to expand the undeveloped subtree + // We then use the next element in path to decided which subtree to traverse + case 'leaf': + return path[0] === '0' + ? Branch(_safeInsertLeaf(leafVal, Leaf('0'), path.slice(1)), Leaf('0')) + : Branch(Leaf('0'), _safeInsertLeaf(leafVal, Leaf('0'), path.slice(1))); + default: + return tree; + } +}; + /** This function is called recursively to traverse down the tree to find check set membership @function _checkMembership @@ -143,6 +173,28 @@ const frontierToTree = timber => { }, timber.tree); }; +/** + This function converts a siblingPath into a tree-like structure. + @function + * @param {object} tree - The tree this sibling path will be inserted into. + * @param {object} siblingPath - The sibling path to be inserted. + * @param {number} index - The leafIndex corresponding to this valid sibling path. + * @param {string} value - The leafValue corresponding to this valid sibling path. + * @returns A tree object updated with this sibling path. + */ +const insertSiblingPath = (tree, siblingPath, index, value) => { + const pathToIndex = Number(index).toString(2).padStart(TIMBER_HEIGHT, '0'); + const siblingIndexPath = siblingPath.path.map((s, idx) => + s.dir === 'left' + ? { value: s.value, path: `${pathToIndex.slice(0, TIMBER_HEIGHT - idx - 1)}0` } + : { value: s.value, path: `${pathToIndex.slice(0, TIMBER_HEIGHT - idx - 1)}1` }, + ); + const allPaths = [{ value, path: pathToIndex }].concat(siblingIndexPath); + return allPaths.reduce((acc, curr) => { + return _safeInsertLeaf(curr.value, acc, curr.path); + }, tree); +}; + /** We do batch insertions when doing stateless operations, the size of each batch is dependent on the tree structure. Each batch insert has to less than or equal to the next closest power of two - otherwise we may unbalance the tree. @@ -631,6 +683,33 @@ class Timber { if (this.leafCount === 0) return []; return Timber.reduceTree((a, b) => [].concat([a, b]).flat(), this.tree); } + + /** + @method + Updates a sibling path statelessly, given the previous path and a set of leaves to update the tree. + * @param {object} timber - The timber instance that contains the frontier and leafCount. + * @param {Array} leaves - The elements that will be inserted. + * @param {number} leafIndex - The index in leaves that the sibling path will be calculated for. + * @param {string} leafValue - The value of the leaf sibling path will be calculated for. + * @param {object} siblingPath - The latest sibling path that for leafIndex that will be updated. + * @returns {object} - Updated siblingPath for leafIndex with leafValue. + */ + static statelessIncrementSiblingPath(timber, leaves, leafIndex, leafValue, siblingPath) { + if (leaves.length === 0 || leafIndex < 0 || leafIndex >= timber.leafCount) return siblingPath; + + const leavesInsertOrder = batchLeaves(timber.leafCount, leaves, []); + // Turn the frontier into a tree-like structure + const newTree = frontierToTree(timber); + // Add the sibling path to the frontier tree-like structure + const siblingTree = insertSiblingPath(newTree, siblingPath, leafIndex, leafValue); + // Add all the new incoming leaves to this tree-like structure + const finalTree = leavesInsertOrder.reduce( + (acc, curr) => acc.insertLeaves(curr), + new Timber(timber.root, timber.frontier, timber.leafCount, siblingTree), + ); + // Get the sibling path for leafIndex from this tree. + return finalTree.getSiblingPath(leafValue, leafIndex); + } } export default Timber; diff --git a/test/timber.test.mjs b/test/timber.test.mjs index 2a17c5f6a..39f7985f4 100644 --- a/test/timber.test.mjs +++ b/test/timber.test.mjs @@ -140,5 +140,49 @@ describe('Local Timber Tests', () => { { numRuns: 20 }, ); }); + it('Check Sibling Path Increment', () => { + fc.assert( + fc.property( + fc.array(randomLeaf, { minLength: 0, maxLength: MAX_ARRAY }), // Remove Duplicates within both arrays + fc.array(randomLeaf, { minLength: 1, maxLength: 32 }), // Remove Duplicates within both arrays + fc.array(randomLeaf, { minLength: 1, maxLength: 32 }), // Remove Duplicates within both arrays + (leaves, addedLeaves, yetMoreLeaves) => { + const leafIndex = Math.max(0, Math.floor(Math.random() * (addedLeaves.length - 1))); + const leafValue = addedLeaves[leafIndex]; + const initialTimber = new Timber().insertLeaves(leaves); + // Get a sibling path after new leaves are added + const statelessMerklePath = Timber.statelessSiblingPath( + initialTimber, + addedLeaves, + leafIndex, + ); + // Update Timber statelessly (no tree) + const statelessUpdate = Timber.statelessUpdate(initialTimber, addedLeaves); + // Given the new state (sans tree), try to increment the sibling path + const statelessIncrementPath = Timber.statelessIncrementSiblingPath( + statelessUpdate, + yetMoreLeaves, + leaves.length + leafIndex, + leafValue, + statelessMerklePath, + ); + const timber = new Timber().insertLeaves( + leaves.concat(addedLeaves).concat(yetMoreLeaves), + ); + const timberMerklePath = timber.getSiblingPath(leafValue); + if (statelessIncrementPath.isMember) { + expect( + Timber.verifySiblingPath(leafValue, timber.root, statelessIncrementPath), + ).to.eql(true); + expect(Timber.verifySiblingPath(leafValue, timber.root, timberMerklePath)).to.eql( + true, + ); + } + expect(statelessIncrementPath.path).to.have.deep.members(timberMerklePath.path); + }, + ), + { numRuns: 20 }, + ); + }); }); }); diff --git a/wallet/src/common-files/classes/timber.js b/wallet/src/common-files/classes/timber.js index cc2203a2c..6e034011f 100644 --- a/wallet/src/common-files/classes/timber.js +++ b/wallet/src/common-files/classes/timber.js @@ -1,3 +1,4 @@ +/* eslint import/no-extraneous-dependencies: "off" */ /* ignore unused exports */ /** @@ -53,6 +54,36 @@ const _insertLeaf = (leafVal, tree, path) => { } }; +/** +This function is like _insertLeaf but it doesnt prune children on insertion +@function _safeInsertLeaf +@param {string} leafVal - The commitment hash to be inserted into the tree +@param {object} tree - The tree where leafVal will be inserted +@param {string} path - The path down tree that leafVal will be inserted into +@returns {object} An updated tree post-insertion but deepest branches are preserved. +*/ +const _safeInsertLeaf = (leafVal, tree, path) => { + if (path.length === 0) { + if (tree.value === '0') return Leaf(leafVal); + return tree; + } + switch (tree.tag) { + // If we are at a branch, we use the next element in path to decide if we go down the left or right subtree. + case 'branch': + return path[0] === '0' + ? Branch(_safeInsertLeaf(leafVal, tree.left, path.slice(1)), tree.right) + : Branch(tree.left, _safeInsertLeaf(leafVal, tree.right, path.slice(1))); + // If we are at a leaf AND path.length > 0, we need to expand the undeveloped subtree + // We then use the next element in path to decided which subtree to traverse + case 'leaf': + return path[0] === '0' + ? Branch(_safeInsertLeaf(leafVal, Leaf('0'), path.slice(1)), Leaf('0')) + : Branch(Leaf('0'), _safeInsertLeaf(leafVal, Leaf('0'), path.slice(1))); + default: + return tree; + } +}; + /** This function is called recursively to traverse down the tree to find check set membership @function _checkMembership @@ -140,6 +171,28 @@ const frontierToTree = timber => { }, timber.tree); }; +/** + This function converts a siblingPath into a tree-like structure. + @function + * @param {object} tree - The tree this sibling path will be inserted into. + * @param {object} siblingPath - The sibling path to be inserted. + * @param {number} index - The leafIndex corresponding to this valid sibling path. + * @param {string} value - The leafValue corresponding to this valid sibling path. + * @returns A tree object updated with this sibling path. + */ +const insertSiblingPath = (tree, siblingPath, index, value) => { + const pathToIndex = Number(index).toString(2).padStart(TIMBER_HEIGHT, '0'); + const siblingIndexPath = siblingPath.path.map((s, idx) => + s.dir === 'left' + ? { value: s.value, path: `${pathToIndex.slice(0, TIMBER_HEIGHT - idx - 1)}0` } + : { value: s.value, path: `${pathToIndex.slice(0, TIMBER_HEIGHT - idx - 1)}1` }, + ); + const allPaths = [{ value, path: pathToIndex }].concat(siblingIndexPath); + return allPaths.reduce((acc, curr) => { + return _safeInsertLeaf(curr.value, acc, curr.path); + }, tree); +}; + /** We do batch insertions when doing stateless operations, the size of each batch is dependent on the tree structure. Each batch insert has to less than or equal to the next closest power of two - otherwise we may unbalance the tree. @@ -628,6 +681,33 @@ class Timber { if (this.leafCount === 0) return []; return Timber.reduceTree((a, b) => [].concat([a, b]).flat(), this.tree); } + + /** + @method + Updates a sibling path statelessly, given the previous path and a set of leaves to update the tree. + * @param {object} timber - The timber instance that contains the frontier and leafCount. + * @param {Array} leaves - The elements that will be inserted. + * @param {number} leafIndex - The index in leaves that the sibling path will be calculated for. + * @param {string} leafValue - The value of the leaf sibling path will be calculated for. + * @param {object} siblingPath - The latest sibling path that for leafIndex that will be updated. + * @returns {object} - Updated siblingPath for leafIndex with leafValue. + */ + static statelessIncrementSiblingPath(timber, leaves, leafIndex, leafValue, siblingPath) { + if (leaves.length === 0 || leafIndex < 0 || leafIndex >= timber.leafCount) return siblingPath; + + const leavesInsertOrder = batchLeaves(timber.leafCount, leaves, []); + // Turn the frontier into a tree-like structure + const newTree = frontierToTree(timber); + // Add the sibling path to the frontier tree-like structure + const siblingTree = insertSiblingPath(newTree, siblingPath, leafIndex, leafValue); + // Add all the new incoming leaves to this tree-like structure + const finalTree = leavesInsertOrder.reduce( + (acc, curr) => acc.insertLeaves(curr), + new Timber(timber.root, timber.frontier, timber.leafCount, siblingTree), + ); + // Get the sibling path for leafIndex from this tree. + return finalTree.getSiblingPath(leafValue, leafIndex); + } } export default Timber;