diff --git a/CHANGELOG.md b/CHANGELOG.md index 726d7a9..3affd11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Add support for custom hash functions. Default to keccak256. + ## 1.0.6 - Added an option to disable leaf sorting. diff --git a/README.md b/README.md index 0e52b6e..f370ac3 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ This library works on "standard" merkle trees designed for Ethereum smart contra - The leaves are sorted. - The leaves are the result of ABI encoding a series of values. - The hash used is Keccak256. -- The leaves are double-hashed[^1] to prevent [second preimage attacks]. +- The leaves are double-hashed[^1] to prevent [second preimage attacks]. Especially useful when the leaves are double the size of the hash function output. [second preimage attacks]: https://flawed.net.nz/2018/02/21/attacking-merkle-trees-with-a-second-preimage-attack/ @@ -166,9 +166,10 @@ Creates a standard merkle tree out of an array of the elements in the tree, alon #### Options -| Option | Description | Default | -| ------------ | ----------------------------------------------------------------------------------- | ------- | -| `sortLeaves` | Enable or disable sorted leaves. Sorting is strongly recommended for multiproofs. | `true` | +| Option | Description | Default | +| ------------ | ----------------------------------------------------------------------------------- | ----------- | +| `sortLeaves` | Enable or disable sorted leaves. Sorting is strongly recommended for multiproofs. | `true` | +| `hashFn` | Custom hashing function. | `keccak256` | ### `StandardMerkleTree.verify` diff --git a/src/bytes.ts b/src/bytes.ts index aed0e0a..dc5e6d2 100644 --- a/src/bytes.ts +++ b/src/bytes.ts @@ -1,7 +1,10 @@ -import { bytesToHex } from 'ethereum-cryptography/utils'; +import { bytesToHex } from "ethereum-cryptography/utils"; export type Bytes = Uint8Array; +export type HashFn = (data: Bytes) => Bytes; +export type HashPair = (a: Bytes, b: Bytes) => Bytes; + export function compareBytes(a: Bytes, b: Bytes): number { const n = Math.min(a.length, b.length); diff --git a/src/core.test.ts b/src/core.test.ts index ac83990..37e89fa 100644 --- a/src/core.test.ts +++ b/src/core.test.ts @@ -1,96 +1,129 @@ -import fc from 'fast-check'; -import assert from 'assert/strict'; -import { equalsBytes } from 'ethereum-cryptography/utils'; -import { makeMerkleTree, getProof, processProof, getMultiProof, processMultiProof, isValidMerkleTree, renderMerkleTree } from './core'; -import { compareBytes, hex } from './bytes'; -import { keccak256 } from 'ethereum-cryptography/keccak'; +import fc from "fast-check"; +import assert from "assert/strict"; +import { equalsBytes } from "ethereum-cryptography/utils"; +import { sha256 } from "ethereum-cryptography/sha256.js"; +import { keccak256 } from "ethereum-cryptography/keccak.js"; +import { + makeMerkleTree, + getProof, + processProof, + getMultiProof, + processMultiProof, + isValidMerkleTree, + renderMerkleTree, +} from "./core"; +import { Bytes, compareBytes, hex } from "./bytes"; +import { hashPair } from "./utils/standard-hash"; const zero = new Uint8Array(32); -const leaf = fc.uint8Array({ minLength: 32, maxLength: 32 }).map(x => PrettyBytes.from(x)); +const leaf = fc + .uint8Array({ minLength: 32, maxLength: 32 }) + .map((x) => PrettyBytes.from(x)); const leaves = fc.array(leaf, { minLength: 1 }); -const leavesAndIndex = leaves.chain(xs => fc.tuple(fc.constant(xs), fc.nat({ max: xs.length - 1 }))); -const leavesAndIndices = leaves.chain(xs => fc.tuple(fc.constant(xs), fc.uniqueArray(fc.nat({ max: xs.length - 1 })))); +const leavesAndIndex = leaves.chain((xs) => + fc.tuple(fc.constant(xs), fc.nat({ max: xs.length - 1 })) +); +const leavesAndIndices = leaves.chain((xs) => + fc.tuple(fc.constant(xs), fc.uniqueArray(fc.nat({ max: xs.length - 1 }))) +); fc.configureGlobal({ numRuns: process.env.CI ? 10000 : 100 }); -describe('core properties', () => { - it('a leaf of a tree is provable', () => { - fc.assert( - fc.property(leavesAndIndex, ([leaves, leafIndex]) => { - const tree = makeMerkleTree(leaves); - const root = tree[0]; - if (root === undefined) return false; - const treeIndex = tree.length - 1 - leafIndex; - const proof = getProof(tree, treeIndex); - const leaf = leaves[leafIndex]!; - const impliedRoot = processProof(leaf, proof); - return equalsBytes(root, impliedRoot); - }), - ); - }); +for (const [name, hashFn] of Object.entries({ + keccak256, + sha256, +})) { + describe(`Using ${name} as the hash function`, () => { + const hashPairFn = (a: Bytes, b: Bytes) => hashPair(a, b, hashFn); - it('a subset of leaves of a tree are provable', () => { - fc.assert( - fc.property(leavesAndIndices, ([leaves, leafIndices]) => { - const tree = makeMerkleTree(leaves); - const root = tree[0]; - if (root === undefined) return false; - const treeIndices = leafIndices.map(i => tree.length - 1 - i); - const proof = getMultiProof(tree, treeIndices); - if (leafIndices.length !== proof.leaves.length) return false; - if (leafIndices.some(i => !proof.leaves.includes(leaves[i]!))) return false; - const impliedRoot = processMultiProof(proof); - return equalsBytes(root, impliedRoot); - }), - ); - }); -}); + describe("core properties", () => { + it("a leaf of a tree is provable", () => { + fc.assert( + fc.property(leavesAndIndex, ([leaves, leafIndex]) => { + const tree = makeMerkleTree(leaves, hashPairFn); + const root = tree[0]; + if (root === undefined) return false; + const treeIndex = tree.length - 1 - leafIndex; + const proof = getProof(tree, treeIndex); + const leaf = leaves[leafIndex]!; + const impliedRoot = processProof(leaf, proof, hashPairFn); + return equalsBytes(root, impliedRoot); + }) + ); + }); -describe('core error conditions', () => { - it('zero leaves', () => { - assert.throws( - () => makeMerkleTree([]), - /^Error: Expected non-zero number of leaves$/, - ); - }); + it("a subset of leaves of a tree are provable", () => { + fc.assert( + fc.property(leavesAndIndices, ([leaves, leafIndices]) => { + const tree = makeMerkleTree(leaves, hashPairFn); + const root = tree[0]; + if (root === undefined) return false; + const treeIndices = leafIndices.map((i) => tree.length - 1 - i); + const proof = getMultiProof(tree, treeIndices); + if (leafIndices.length !== proof.leaves.length) return false; + if (leafIndices.some((i) => !proof.leaves.includes(leaves[i]!))) + return false; + const impliedRoot = processMultiProof(proof, hashPairFn); + return equalsBytes(root, impliedRoot); + }) + ); + }); + }); - it('multiproof duplicate index', () => { - const tree = makeMerkleTree(new Array(2).fill(zero)); - assert.throws( - () => getMultiProof(tree, [1, 1]), - /^Error: Cannot prove duplicated index$/, - ); - }); + describe("core error conditions", () => { + it("zero leaves", () => { + assert.throws( + () => makeMerkleTree([], hashPairFn), + /^Error: Expected non-zero number of leaves$/ + ); + }); - it('tree validity', () => { - assert(!isValidMerkleTree([]), 'empty tree'); - assert(!isValidMerkleTree([zero, zero]), 'even number of nodes'); - assert(!isValidMerkleTree([zero, zero, zero]), 'inner node not hash of children'); + it("multiproof duplicate index", () => { + const tree = makeMerkleTree(new Array(2).fill(zero), hashPairFn); + assert.throws( + () => getMultiProof(tree, [1, 1]), + /^Error: Cannot prove duplicated index$/ + ); + }); - assert.throws( - () => renderMerkleTree([]), - /^Error: Expected non-zero number of nodes$/, - ); - }); + it("tree validity", () => { + assert(!isValidMerkleTree([], hashPairFn), "empty tree"); + assert( + !isValidMerkleTree([zero, zero], hashPairFn), + "even number of nodes" + ); + assert( + !isValidMerkleTree([zero, zero, zero], hashPairFn), + "inner node not hash of children" + ); - it('multiproof invariants', () => { - const leaf = keccak256(Uint8Array.of(42)); - const tree = makeMerkleTree([leaf, zero]); + assert.throws( + () => renderMerkleTree([]), + /^Error: Expected non-zero number of nodes$/ + ); + }); - const badMultiProof = { - leaves: [128, 129].map(n => keccak256(Uint8Array.of(n))).sort(compareBytes), - proof: [leaf, leaf], - proofFlags: [true, true, false], - }; + it("multiproof invariants", () => { + const leaf = keccak256(Uint8Array.of(42)); + const tree = makeMerkleTree([leaf, zero], hashPairFn); - assert.throws( - () => processMultiProof(badMultiProof), - /^Error: Broken invariant$/, - ); - }); + const badMultiProof = { + leaves: [128, 129] + .map((n) => keccak256(Uint8Array.of(n))) + .sort(compareBytes), + proof: [leaf, leaf], + proofFlags: [true, true, false], + }; -}); + assert.throws( + () => processMultiProof(badMultiProof, hashPairFn), + /^Error: Broken invariant$/ + ); + }); + }); + }); +} class PrettyBytes extends Uint8Array { [fc.toStringMethod]() { diff --git a/src/core.ts b/src/core.ts index a44c405..9a41d34 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,10 +1,7 @@ -import { keccak256 } from 'ethereum-cryptography/keccak'; -import { concatBytes, bytesToHex, equalsBytes } from 'ethereum-cryptography/utils'; -import { Bytes, compareBytes } from './bytes'; +import { bytesToHex, equalsBytes } from 'ethereum-cryptography/utils'; +import { Bytes, HashPair } from './bytes'; import { throwError } from './utils/throw-error'; -const hashPair = (a: Bytes, b: Bytes) => keccak256(concatBytes(...[a, b].sort(compareBytes))); - const leftChildIndex = (i: number) => 2 * i + 1; const rightChildIndex = (i: number) => 2 * i + 2; const parentIndex = (i: number) => i > 0 ? Math.floor((i - 1) / 2) : throwError('Root has no parent'); @@ -15,12 +12,10 @@ const isInternalNode = (tree: unknown[], i: number) => isTreeNode(tree, leftC const isLeafNode = (tree: unknown[], i: number) => isTreeNode(tree, i) && !isInternalNode(tree, i); const isValidMerkleNode = (node: Bytes) => node instanceof Uint8Array && node.length === 32; -const checkTreeNode = (tree: unknown[], i: number) => void (isTreeNode(tree, i) || throwError('Index is not in tree')); -const checkInternalNode = (tree: unknown[], i: number) => void (isInternalNode(tree, i) || throwError('Index is not an internal tree node')); const checkLeafNode = (tree: unknown[], i: number) => void (isLeafNode(tree, i) || throwError('Index is not a leaf')); const checkValidMerkleNode = (node: Bytes) => void (isValidMerkleNode(node) || throwError('Merkle tree nodes must be Uint8Array of length 32')); -export function makeMerkleTree(leaves: Bytes[]): Bytes[] { +export function makeMerkleTree(leaves: Bytes[], hashPair: HashPair): Bytes[] { leaves.forEach(checkValidMerkleNode); if (leaves.length === 0) { @@ -53,7 +48,7 @@ export function getProof(tree: Bytes[], index: number): Bytes[] { return proof; } -export function processProof(leaf: Bytes, proof: Bytes[]): Bytes { +export function processProof(leaf: Bytes, proof: Bytes[], hashPair: HashPair): Bytes { checkValidMerkleNode(leaf); proof.forEach(checkValidMerkleNode); @@ -104,7 +99,7 @@ export function getMultiProof(tree: Bytes[], indices: number[]): MultiProof): Bytes { +export function processMultiProof(multiproof: MultiProof, hashPair: HashPair): Bytes { multiproof.leaves.forEach(checkValidMerkleNode); multiproof.proof.forEach(checkValidMerkleNode); @@ -135,7 +130,7 @@ export function processMultiProof(multiproof: MultiProof): Bytes { return stack.pop() ?? proof.shift()!; } -export function isValidMerkleTree(tree: Bytes[]): boolean { +export function isValidMerkleTree(tree: Bytes[], hashPair: HashPair): boolean { for (const [i, node] of tree.entries()) { if (!isValidMerkleNode(node)) { return false; diff --git a/src/options.ts b/src/options.ts index 3516460..94d8134 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,11 +1,18 @@ +import { HashFn } from "./bytes"; +import { standardHash } from "./utils/standard-hash"; + // MerkleTree building options export type MerkleTreeOptions = Partial<{ /** Enable or disable sorted leaves. Sorting is strongly recommended for multiproofs. */ sortLeaves: boolean; + /** Hashing function. Defaults to a sorted keccak256. */ + hashFn: HashFn; }>; // Recommended (default) options. // - leaves are sorted by default to facilitate onchain verification of multiproofs. +// - keccak256 is used by default for hashing. export const defaultOptions: Required = { sortLeaves: true, + hashFn: standardHash, }; diff --git a/src/standard.test.ts b/src/standard.test.ts index cd1217d..26728ad 100644 --- a/src/standard.test.ts +++ b/src/standard.test.ts @@ -1,149 +1,223 @@ -import assert from 'assert/strict'; -import { keccak256 } from 'ethereum-cryptography/keccak'; -import { hex } from './bytes'; -import { MerkleTreeOptions } from './options'; -import { StandardMerkleTree } from './standard'; +import assert from "assert/strict"; +import { sha256 } from "ethereum-cryptography/sha256.js"; +import { keccak256 } from "ethereum-cryptography/keccak.js"; +import { hex } from "./bytes"; +import { MerkleTreeOptions } from "./options"; +import { StandardMerkleTree } from "./standard"; const zeroBytes = new Uint8Array(32); const zero = hex(zeroBytes); const makeTree = (s: string, opts: MerkleTreeOptions = {}) => { - const l = s.split('').map(c => [c]); - const t = StandardMerkleTree.of(l, ['string'], opts); + const l = s.split("").map((c) => [c]); + const t = StandardMerkleTree.of(l, ["string"], opts); return { l, t }; -} - -describe('standard merkle tree', () => { - for (const opts of [ - {}, - { sortLeaves: true }, - { sortLeaves: false }, - ]) { - describe(`with options '${JSON.stringify(opts)}'`, () => { - const { l: leaves, t: tree } = makeTree('abcdef', opts); - const { l: otherLeaves, t: otherTree } = makeTree('abc', opts); - - it('generates valid single proofs for all leaves', () => { - tree.validate(); - }); - - it('generates valid single proofs for all leaves', () => { - for (const [id, leaf] of tree.entries()) { - const proof1 = tree.getProof(id); - const proof2 = tree.getProof(leaf); - - assert.deepEqual(proof1, proof2); - - assert(tree.verify(id, proof1)); - assert(tree.verify(leaf, proof1)); - assert(StandardMerkleTree.verify(tree.root, ['string'], leaf, proof1)); - } - }); - - it('rejects invalid proofs', () => { - const leaf = ['a']; - const invalidProof = otherTree.getProof(leaf); - - assert(!tree.verify(leaf, invalidProof)); - assert(!StandardMerkleTree.verify(tree.root, ['string'], leaf, invalidProof)); - }); - - it('generates valid multiproofs', () => { - for (const ids of [[], [0, 1], [0, 1, 5], [1, 3, 4, 5], [0, 2, 4, 5], [0, 1, 2, 3, 4, 5], [4, 1, 5, 0, 2]]) { - const proof1 = tree.getMultiProof(ids); - const proof2 = tree.getMultiProof(ids.map(i => leaves[i]!)); - - assert.deepEqual(proof1, proof2); - - assert(tree.verifyMultiProof(proof1)); - assert(StandardMerkleTree.verifyMultiProof(tree.root, ['string'], proof1)); - } - }); - - it('rejects invalid multiproofs', () => { - const multiProof = otherTree.getMultiProof([['a'], ['b'], ['c']]); - - assert(!tree.verifyMultiProof(multiProof)); - assert(!StandardMerkleTree.verifyMultiProof(tree.root, ['string'], multiProof)); - }); - - it('renders tree representation', () => { - assert.equal( - tree.render(), - opts.sortLeaves == false - ? [ - "0) 23be0977360f08bb0bd7f709a7d543d2cd779c79c66d74e0441919871647de2b", - "├─ 1) 8f7234e8cfe39c08ca84a3a3e3274f574af26fd15165fe29e09cbab742daccd9", - "│ ├─ 3) 03707d7802a71ca56a8ad8028da98c4f1dbec55b31b4a25d536b5309cc20eda9", - "│ │ ├─ 7) eba909cf4bb90c6922771d7f126ad0fd11dfde93f3937a196274e1ac20fd2f5b", - "│ │ └─ 8) 9cf5a63718145ba968a01c1d557020181c5b252f665cf7386d370eddb176517b", - "│ └─ 4) fa914d99a18dc32d9725b3ef1c50426deb40ec8d0885dac8edcc5bfd6d030016", - "│ ├─ 9) 19ba6c6333e0e9a15bf67523e0676e2f23eb8e574092552d5e888c64a4bb3681", - "│ └─ 10) 9c15a6a0eaeed500fd9eed4cbeab71f797cefcc67bfd46683e4d2e6ff7f06d1c", - "└─ 2) 7b0c6cd04b82bfc0e250030a5d2690c52585e0cc6a4f3bc7909d7723b0236ece", - " ├─ 5) c62a8cfa41edc0ef6f6ae27a2985b7d39c7fea770787d7e104696c6e81f64848", - " └─ 6) 9a4f64e953595df82d1b4f570d34c4f4f0cfaf729a61e9d60e83e579e1aa283e", - ].join("\n") - : [ - "0) 6deb52b5da8fd108f79fab00341f38d2587896634c646ee52e49f845680a70c8", - "├─ 1) 52426e0f1f65ff7e209a13b8c29cffe82e3acaf3dad0a9b9088f3b9a61a929c3", - "│ ├─ 3) 8076923e76cf01a7c048400a2304c9a9c23bbbdac3a98ea3946340fdafbba34f", - "│ │ ├─ 7) 9cf5a63718145ba968a01c1d557020181c5b252f665cf7386d370eddb176517b", - "│ │ └─ 8) 9c15a6a0eaeed500fd9eed4cbeab71f797cefcc67bfd46683e4d2e6ff7f06d1c", - "│ └─ 4) 965b92c6cf08303cc4feb7f3e0819c436c2cec17c6f0688a6af139c9a368707c", - "│ ├─ 9) 9a4f64e953595df82d1b4f570d34c4f4f0cfaf729a61e9d60e83e579e1aa283e", - "│ └─ 10) 19ba6c6333e0e9a15bf67523e0676e2f23eb8e574092552d5e888c64a4bb3681", - "└─ 2) fd3cf45654e88d1cc5d663578c82c76f4b5e3826bacaa1216441443504538f51", - " ├─ 5) eba909cf4bb90c6922771d7f126ad0fd11dfde93f3937a196274e1ac20fd2f5b", - " └─ 6) c62a8cfa41edc0ef6f6ae27a2985b7d39c7fea770787d7e104696c6e81f64848", - ].join("\n"), - ); - }); - - it('dump and load', () => { - const recoveredTree = StandardMerkleTree.load(tree.dump()); - - recoveredTree.validate(); - assert.deepEqual(tree, recoveredTree); - }); - - it('reject out of bounds value index', () => { - assert.throws( - () => tree.getProof(leaves.length), - /^Error: Index out of bounds$/, - ); - }); - - it('reject unrecognized tree dump', () => { - assert.throws( - () => StandardMerkleTree.load({ format: 'nonstandard' } as any), - /^Error: Unknown format 'nonstandard'$/, - ); - }); - - it('reject malformed tree dump', () => { - const loadedTree1 = StandardMerkleTree.load({ - format: 'standard-v1', - tree: [zero], - values: [{ value: ['0'], treeIndex: 0 }], - leafEncoding: ['uint256'], +}; + +describe("standard merkle tree", () => { + for (const [name, hashFn] of Object.entries({ + keccak256, + sha256, + })) { + describe(`using ${name} as the hash function`, () => { + for (const optsWithoutHash of [ + {}, + { sortLeaves: true }, + { sortLeaves: false }, + ]) { + const opts = { ...optsWithoutHash, hashFn }; + describe(`with options '${JSON.stringify(opts)}'`, () => { + const { l: leaves, t: tree } = makeTree("abcdef", opts); + const { t: otherTree } = makeTree("abc", opts); + + it("generates a valid tree", () => { + tree.validate(); + }); + + it("generates valid single proofs for all leaves", () => { + for (const [id, leaf] of tree.entries()) { + const proof1 = tree.getProof(id); + const proof2 = tree.getProof(leaf); + + assert.deepEqual(proof1, proof2); + + assert(tree.verify(id, proof1)); + assert(tree.verify(leaf, proof1)); + assert( + StandardMerkleTree.verify( + tree.root, + ["string"], + leaf, + proof1, + hashFn + ) + ); + } + }); + + it("rejects invalid proofs", () => { + const leaf = ["a"]; + const invalidProof = otherTree.getProof(leaf); + + assert(!tree.verify(leaf, invalidProof)); + assert( + !StandardMerkleTree.verify( + tree.root, + ["string"], + leaf, + invalidProof + ) + ); + }); + + it("generates valid multiproofs", () => { + for (const ids of [ + [], + [0, 1], + [0, 1, 5], + [1, 3, 4, 5], + [0, 2, 4, 5], + [0, 1, 2, 3, 4, 5], + [4, 1, 5, 0, 2], + ]) { + const proof1 = tree.getMultiProof(ids); + const proof2 = tree.getMultiProof(ids.map((i) => leaves[i]!)); + + assert.deepEqual(proof1, proof2); + + assert(tree.verifyMultiProof(proof1)); + assert( + StandardMerkleTree.verifyMultiProof( + tree.root, + ["string"], + proof1, + hashFn + ) + ); + } + }); + + it("rejects invalid multiproofs", () => { + const multiProof = otherTree.getMultiProof([["a"], ["b"], ["c"]]); + + assert(!tree.verifyMultiProof(multiProof)); + assert( + !StandardMerkleTree.verifyMultiProof( + tree.root, + ["string"], + multiProof + ) + ); + }); + + it("renders tree representation", () => { + const trees: Record = { + keccak256: + opts.sortLeaves == false + ? [ + "0) 23be0977360f08bb0bd7f709a7d543d2cd779c79c66d74e0441919871647de2b", + "├─ 1) 8f7234e8cfe39c08ca84a3a3e3274f574af26fd15165fe29e09cbab742daccd9", + "│ ├─ 3) 03707d7802a71ca56a8ad8028da98c4f1dbec55b31b4a25d536b5309cc20eda9", + "│ │ ├─ 7) eba909cf4bb90c6922771d7f126ad0fd11dfde93f3937a196274e1ac20fd2f5b", + "│ │ └─ 8) 9cf5a63718145ba968a01c1d557020181c5b252f665cf7386d370eddb176517b", + "│ └─ 4) fa914d99a18dc32d9725b3ef1c50426deb40ec8d0885dac8edcc5bfd6d030016", + "│ ├─ 9) 19ba6c6333e0e9a15bf67523e0676e2f23eb8e574092552d5e888c64a4bb3681", + "│ └─ 10) 9c15a6a0eaeed500fd9eed4cbeab71f797cefcc67bfd46683e4d2e6ff7f06d1c", + "└─ 2) 7b0c6cd04b82bfc0e250030a5d2690c52585e0cc6a4f3bc7909d7723b0236ece", + " ├─ 5) c62a8cfa41edc0ef6f6ae27a2985b7d39c7fea770787d7e104696c6e81f64848", + " └─ 6) 9a4f64e953595df82d1b4f570d34c4f4f0cfaf729a61e9d60e83e579e1aa283e", + ].join("\n") + : [ + "0) 6deb52b5da8fd108f79fab00341f38d2587896634c646ee52e49f845680a70c8", + "├─ 1) 52426e0f1f65ff7e209a13b8c29cffe82e3acaf3dad0a9b9088f3b9a61a929c3", + "│ ├─ 3) 8076923e76cf01a7c048400a2304c9a9c23bbbdac3a98ea3946340fdafbba34f", + "│ │ ├─ 7) 9cf5a63718145ba968a01c1d557020181c5b252f665cf7386d370eddb176517b", + "│ │ └─ 8) 9c15a6a0eaeed500fd9eed4cbeab71f797cefcc67bfd46683e4d2e6ff7f06d1c", + "│ └─ 4) 965b92c6cf08303cc4feb7f3e0819c436c2cec17c6f0688a6af139c9a368707c", + "│ ├─ 9) 9a4f64e953595df82d1b4f570d34c4f4f0cfaf729a61e9d60e83e579e1aa283e", + "│ └─ 10) 19ba6c6333e0e9a15bf67523e0676e2f23eb8e574092552d5e888c64a4bb3681", + "└─ 2) fd3cf45654e88d1cc5d663578c82c76f4b5e3826bacaa1216441443504538f51", + " ├─ 5) eba909cf4bb90c6922771d7f126ad0fd11dfde93f3937a196274e1ac20fd2f5b", + " └─ 6) c62a8cfa41edc0ef6f6ae27a2985b7d39c7fea770787d7e104696c6e81f64848", + ].join("\n"), + sha256: + opts.sortLeaves == false + ? [ + "0) 64abc0328f56ee3e6740935b8fabd5cfc5f8f349280c507ebec0c3b2aaf0263e", + "├─ 1) 03f6ca6a0d90f9898aa8837b8c2c39d1d4ae103af7beb3ef05dca81e1ddb10dd", + "│ ├─ 3) 9447096bf50a26f0630f925d54f7df3905d3e8a5e335758b9dde66ec974ff0eb", + "│ │ ├─ 7) 3de5d9a36614e3159b197e6a4c334e0997b5037acb406e7d58b2ebc156a01b40", + "│ │ └─ 8) ee8476cf31e3608c6ef618451476c5513ceef7f6d9f4af12df9fd4e4501210c3", + "│ └─ 4) 002e780c6030fe13117d4a110401277d849dfff49312ecd0063cc0f96560a703", + "│ ├─ 9) 9a81c362fd809d46eb23f6920461ce343f9384bae29e11d005990fd2fbfb78c2", + "│ └─ 10) b9e9db137d987ce376feabe4acc5ee8b23a2d460699cc8bd7e1fe001cbd99df0", + "└─ 2) 7d3e71cf65adc8d233787103152eabe439b15329b01c1d887c227236cad02ee5", + " ├─ 5) 0b623bf3b3a650a8072bc8b3001b2b74d7e63b43bf81beb332e536207b4a58e7", + " └─ 6) ef53964d3736e523a79fe02137c6dba7d2b151fea57aa43c6f637514f2303f72", + ].join("\n") + : [ + "0) bf7fd7dd7e7fc203067d67694317b8d3f64aed57435c34af38e6248dc546a18f", + "├─ 1) ae76a8316586158bc19b336389bf7e7a819268ed30f9b8bb64d131584c21d210", + "│ ├─ 3) 002e780c6030fe13117d4a110401277d849dfff49312ecd0063cc0f96560a703", + "│ │ ├─ 7) b9e9db137d987ce376feabe4acc5ee8b23a2d460699cc8bd7e1fe001cbd99df0", + "│ │ └─ 8) 9a81c362fd809d46eb23f6920461ce343f9384bae29e11d005990fd2fbfb78c2", + "│ └─ 4) f9ac9bf8f03ff8ed7a0934b5916a482c005d06cf9c8131c6c1981d64b0a8c641", + "│ ├─ 9) 3de5d9a36614e3159b197e6a4c334e0997b5037acb406e7d58b2ebc156a01b40", + "│ └─ 10) 0b623bf3b3a650a8072bc8b3001b2b74d7e63b43bf81beb332e536207b4a58e7", + "└─ 2) 7c083306bbcf9c8c9e5d2a4151dc2fc059eb4ebc462fb907826a71cdcb2fae0c", + " ├─ 5) ef53964d3736e523a79fe02137c6dba7d2b151fea57aa43c6f637514f2303f72", + " └─ 6) ee8476cf31e3608c6ef618451476c5513ceef7f6d9f4af12df9fd4e4501210c3", + ].join("\n"), + }; + assert.equal(tree.render(), trees[name]); + }); + + it("dump and load", () => { + const recoveredTree = StandardMerkleTree.load(tree.dump(), hashFn); + + recoveredTree.validate(); + assert.deepEqual(tree, recoveredTree); + }); + + it("reject out of bounds value index", () => { + assert.throws( + () => tree.getProof(leaves.length), + /^Error: Index out of bounds$/ + ); + }); + + it("reject unrecognized tree dump", () => { + assert.throws( + () => StandardMerkleTree.load({ format: "nonstandard" } as any), + /^Error: Unknown format 'nonstandard'$/ + ); + }); + + it("reject malformed tree dump", () => { + const loadedTree1 = StandardMerkleTree.load({ + format: "standard-v1", + tree: [zero], + values: [{ value: ["0"], treeIndex: 0 }], + leafEncoding: ["uint256"], + }); + assert.throws( + () => loadedTree1.getProof(0), + /^Error: Merkle tree does not contain the expected value$/ + ); + + const loadedTree2 = StandardMerkleTree.load({ + format: "standard-v1", + tree: [zero, zero, hex(keccak256(keccak256(zeroBytes)))], + values: [{ value: ["0"], treeIndex: 2 }], + leafEncoding: ["uint256"], + }); + assert.throws( + () => loadedTree2.getProof(0), + /^Error: Unable to prove value$/ + ); + }); }); - assert.throws( - () => loadedTree1.getProof(0), - /^Error: Merkle tree does not contain the expected value$/, - ); - - const loadedTree2 = StandardMerkleTree.load({ - format: 'standard-v1', - tree: [zero, zero, hex(keccak256(keccak256(zeroBytes)))], - values: [{ value: ['0'], treeIndex: 2 }], - leafEncoding: ['uint256'], - }); - assert.throws( - () => loadedTree2.getProof(0), - /^Error: Unable to prove value$/, - ); - }); + } }); } }); diff --git a/src/standard.ts b/src/standard.ts index 20b40e1..a5c7354 100644 --- a/src/standard.ts +++ b/src/standard.ts @@ -1,10 +1,10 @@ import { equalsBytes, hexToBytes } from 'ethereum-cryptography/utils'; -import { Bytes, compareBytes, hex } from './bytes'; +import { Bytes, HashFn, compareBytes, hex } from './bytes'; import { getProof, isValidMerkleTree, makeMerkleTree, processProof, renderMerkleTree, MultiProof, getMultiProof, processMultiProof } from './core'; import { MerkleTreeOptions, defaultOptions } from './options'; import { checkBounds } from './utils/check-bounds'; import { throwError } from './utils/throw-error'; -import { standardLeafHash } from './utils/standard-leaf-hash'; +import { hashPair, leafHash, standardHash } from './utils/standard-hash'; interface StandardMerkleTreeData { format: 'standard-v1'; @@ -23,34 +23,43 @@ export class StandardMerkleTree { private readonly tree: Bytes[], private readonly values: { value: T, treeIndex: number }[], private readonly leafEncoding: string[], + private readonly hashFn: HashFn, ) { this.hashLookup = Object.fromEntries(values.map(({ value }, valueIndex) => [ - hex(standardLeafHash(value, leafEncoding)), + hex(leafHash(value, leafEncoding, this.hashFn)), valueIndex, ])); } static of(values: T[], leafEncoding: string[], options: MerkleTreeOptions = {}) { + const hashFn = options.hashFn ?? defaultOptions.hashFn; const sortLeaves = options.sortLeaves ?? defaultOptions.sortLeaves; - const hashedValues = values.map((value, valueIndex) => ({ value, valueIndex, hash: standardLeafHash(value, leafEncoding) })); + const hashedValues = values.map((value, valueIndex) => ({ + value, + valueIndex, + hash: leafHash(value, leafEncoding, hashFn), + })); if (sortLeaves) { hashedValues.sort((a, b) => compareBytes(a.hash, b.hash)); } - const tree = makeMerkleTree(hashedValues.map(v => v.hash)); + const tree = makeMerkleTree( + hashedValues.map((v) => v.hash), + (a, b) => hashPair(a, b, hashFn) + ); const indexedValues = values.map(value => ({ value, treeIndex: 0 })); for (const [leafIndex, { valueIndex }] of hashedValues.entries()) { indexedValues[valueIndex]!.treeIndex = tree.length - leafIndex - 1; } - return new StandardMerkleTree(tree, indexedValues, leafEncoding); + return new StandardMerkleTree(tree, indexedValues, leafEncoding, hashFn); } - - static load(data: StandardMerkleTreeData): StandardMerkleTree { + + static load(data: StandardMerkleTreeData, hashFn?: HashFn): StandardMerkleTree { if (data.format !== 'standard-v1') { throw new Error(`Unknown format '${data.format}'`); } @@ -58,23 +67,27 @@ export class StandardMerkleTree { data.tree.map(hexToBytes), data.values, data.leafEncoding, + hashFn ?? standardHash, ); } - static verify(root: string, leafEncoding: string[], leaf: T, proof: string[]): boolean { - const impliedRoot = processProof(standardLeafHash(leaf, leafEncoding), proof.map(hexToBytes)); + + static verify(root: string, leafEncoding: string[], leaf: T, proof: string[], hashFn?: HashFn): boolean { + const hash = hashFn ?? standardHash; + const impliedRoot = processProof(leafHash(leaf, leafEncoding, hash), proof.map(hexToBytes), (a, b) => hashPair(a, b, hash)); return equalsBytes(impliedRoot, hexToBytes(root)); } - static verifyMultiProof(root: string, leafEncoding: string[], multiproof: MultiProof): boolean { - const leafHashes = multiproof.leaves.map(leaf => standardLeafHash(leaf, leafEncoding)); + static verifyMultiProof(root: string, leafEncoding: string[], multiproof: MultiProof, hashFn?: HashFn): boolean { + const hash = hashFn ?? standardHash; + const leafHashes = multiproof.leaves.map(leaf => leafHash(leaf, leafEncoding, hash)); const proofBytes = multiproof.proof.map(hexToBytes); const impliedRoot = processMultiProof({ leaves: leafHashes, proof: proofBytes, proofFlags: multiproof.proofFlags, - }); + }, (a, b) => hashPair(a, b, hash)); return equalsBytes(impliedRoot, hexToBytes(root)); } @@ -84,7 +97,7 @@ export class StandardMerkleTree { format: 'standard-v1', tree: this.tree.map(hex), values: this.values, - leafEncoding: this.leafEncoding, + leafEncoding: this.leafEncoding }; } @@ -106,13 +119,13 @@ export class StandardMerkleTree { for (let i = 0; i < this.values.length; i++) { this.validateValue(i); } - if (!isValidMerkleTree(this.tree)) { + if (!isValidMerkleTree(this.tree, this.hashPair.bind(this))) { throw new Error('Merkle tree is invalid'); } } leafHash(leaf: T): string { - return hex(standardLeafHash(leaf, this.leafEncoding)); + return hex(leafHash(leaf, this.leafEncoding, this.hashFn)); } leafLookup(leaf: T): number { @@ -164,7 +177,7 @@ export class StandardMerkleTree { } private _verify(leafHash: Bytes, proof: Bytes[]): boolean { - const impliedRoot = processProof(leafHash, proof); + const impliedRoot = processProof(leafHash, proof, this.hashPair.bind(this)); return equalsBytes(impliedRoot, this.tree[0]!); } @@ -177,7 +190,7 @@ export class StandardMerkleTree { } private _verifyMultiProof(multiproof: MultiProof): boolean { - const impliedRoot = processMultiProof(multiproof); + const impliedRoot = processMultiProof(multiproof, this.hashPair.bind(this)); return equalsBytes(impliedRoot, this.tree[0]!); } @@ -185,7 +198,7 @@ export class StandardMerkleTree { checkBounds(this.values, valueIndex); const { value, treeIndex } = this.values[valueIndex]!; checkBounds(this.tree, treeIndex); - const leaf = standardLeafHash(value, this.leafEncoding); + const leaf = leafHash(value, this.leafEncoding, this.hashFn); if (!equalsBytes(leaf, this.tree[treeIndex]!)) { throw new Error('Merkle tree does not contain the expected value'); } @@ -196,7 +209,11 @@ export class StandardMerkleTree { if (typeof leaf === 'number') { return this.validateValue(leaf); } else { - return standardLeafHash(leaf, this.leafEncoding); + return leafHash(leaf, this.leafEncoding, this.hashFn); } } + + private hashPair(a: Bytes, b: Bytes): Bytes { + return hashPair(a, b, this.hashFn); + } } diff --git a/src/utils/standard-hash.ts b/src/utils/standard-hash.ts new file mode 100644 index 0000000..18d112b --- /dev/null +++ b/src/utils/standard-hash.ts @@ -0,0 +1,16 @@ +import { keccak256 } from 'ethereum-cryptography/keccak'; +import { concatBytes, hexToBytes } from 'ethereum-cryptography/utils'; +import { defaultAbiCoder } from '@ethersproject/abi'; +import { Bytes, HashFn, compareBytes } from '../bytes'; + +export function standardHash(value: Bytes): Bytes { + return keccak256(value); +} + +export function leafHash(value: T, types: string[], hashFn: HashFn): Bytes { + return hashFn(hashFn(hexToBytes(defaultAbiCoder.encode(types, value)))); +} + +export function hashPair(a: Bytes, b: Bytes, hashFn: HashFn): Bytes { + return hashFn(concatBytes(...[a, b].sort(compareBytes))) +} diff --git a/src/utils/standard-leaf-hash.ts b/src/utils/standard-leaf-hash.ts deleted file mode 100644 index df65671..0000000 --- a/src/utils/standard-leaf-hash.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { keccak256 } from 'ethereum-cryptography/keccak'; -import { hexToBytes } from 'ethereum-cryptography/utils'; -import { defaultAbiCoder } from '@ethersproject/abi'; -import { Bytes } from '../bytes'; - -export function standardLeafHash(value: T, types: string[]): Bytes { - return keccak256(keccak256(hexToBytes(defaultAbiCoder.encode(types, value)))); -}