diff --git a/src/core.ts b/src/core.ts index b523ba7..c02d267 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,9 +1,7 @@ -import { keccak256 } from '@ethersproject/keccak256'; -import { BytesLike, HexString, toHex, toBytes, concat, compare } from './bytes'; +import { BytesLike, HexString, toHex, toBytes, compare } from './bytes'; +import { NodeHash, standardNodeHash } from './hashes'; import { invariant, throwError, validateArgument } from './utils/errors'; -const hashPair = (a: BytesLike, b: BytesLike): HexString => keccak256(concat([a, b].sort(compare))); - 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')); @@ -18,7 +16,7 @@ const checkLeafNode = (tree: unknown[], i: number) => void (isLeafNode(tree, i) const checkValidMerkleNode = (node: BytesLike) => void (isValidMerkleNode(node) || throwError('Merkle tree nodes must be Uint8Array of length 32')); -export function makeMerkleTree(leaves: BytesLike[]): HexString[] { +export function makeMerkleTree(leaves: BytesLike[], nodeHash: NodeHash = standardNodeHash): HexString[] { leaves.forEach(checkValidMerkleNode); validateArgument(leaves.length !== 0, 'Expected non-zero number of leaves'); @@ -29,7 +27,7 @@ export function makeMerkleTree(leaves: BytesLike[]): HexString[] { tree[tree.length - 1 - i] = toHex(leaf); } for (let i = tree.length - 1 - leaves.length; i >= 0; i--) { - tree[i] = hashPair(tree[leftChildIndex(i)]!, tree[rightChildIndex(i)]!); + tree[i] = nodeHash(tree[leftChildIndex(i)]!, tree[rightChildIndex(i)]!); } return tree; @@ -46,11 +44,11 @@ export function getProof(tree: BytesLike[], index: number): HexString[] { return proof; } -export function processProof(leaf: BytesLike, proof: BytesLike[]): HexString { +export function processProof(leaf: BytesLike, proof: BytesLike[], nodeHash: NodeHash = standardNodeHash): HexString { checkValidMerkleNode(leaf); proof.forEach(checkValidMerkleNode); - return toHex(proof.reduce(hashPair, leaf)); + return toHex(proof.reduce(nodeHash, leaf)); } export interface MultiProof { @@ -68,7 +66,7 @@ export function getMultiProof(tree: BytesLike[], indices: number[]): MultiProof< 'Cannot prove duplicated index', ); - const stack = indices.concat(); // copy + const stack = Array.from(indices); // copy const proof = []; const proofFlags = []; @@ -98,7 +96,7 @@ export function getMultiProof(tree: BytesLike[], indices: number[]): MultiProof< }; } -export function processMultiProof(multiproof: MultiProof): HexString { +export function processMultiProof(multiproof: MultiProof, nodeHash: NodeHash = standardNodeHash): HexString { multiproof.leaves.forEach(checkValidMerkleNode); multiproof.proof.forEach(checkValidMerkleNode); @@ -111,14 +109,14 @@ export function processMultiProof(multiproof: MultiProof): HexString 'Provided leaves and multiproof are not compatible', ); - const stack = multiproof.leaves.concat(); // copy - const proof = multiproof.proof.concat(); // copy + const stack = Array.from(multiproof.leaves); // copy + const proof = Array.from(multiproof.proof); // copy for (const flag of multiproof.proofFlags) { const a = stack.shift(); const b = flag ? stack.shift() : proof.shift(); invariant(a !== undefined && b !== undefined); - stack.push(hashPair(a, b)); + stack.push(nodeHash(a, b)); } invariant(stack.length + proof.length === 1); @@ -126,7 +124,7 @@ export function processMultiProof(multiproof: MultiProof): HexString return toHex(stack.pop() ?? proof.shift()!); } -export function isValidMerkleTree(tree: BytesLike[]): boolean { +export function isValidMerkleTree(tree: BytesLike[], nodeHash: NodeHash = standardNodeHash): boolean { for (const [i, node] of tree.entries()) { if (!isValidMerkleNode(node)) { return false; @@ -139,7 +137,7 @@ export function isValidMerkleTree(tree: BytesLike[]): boolean { if (l < tree.length) { return false; } - } else if (compare(node, hashPair(tree[l]!, tree[r]!))) { + } else if (compare(node, nodeHash(tree[l]!, tree[r]!))) { return false; } } diff --git a/src/hashes.ts b/src/hashes.ts new file mode 100644 index 0000000..fcda8b6 --- /dev/null +++ b/src/hashes.ts @@ -0,0 +1,14 @@ +import { defaultAbiCoder } from '@ethersproject/abi'; +import { keccak256 } from '@ethersproject/keccak256'; +import { BytesLike, HexString, concat, compare } from './bytes'; + +export type LeafHash = (leaf: T) => HexString; +export type NodeHash = (left: BytesLike, right: BytesLike) => HexString; + +export function standardLeafHash(types: string[], value: T): HexString { + return keccak256(keccak256(defaultAbiCoder.encode(types, value))); +} + +export function standardNodeHash(a: BytesLike, b: BytesLike): HexString { + return keccak256(concat([a, b].sort(compare))); +} diff --git a/src/merkletree.ts b/src/merkletree.ts index bff1b1f..eda1b81 100644 --- a/src/merkletree.ts +++ b/src/merkletree.ts @@ -12,6 +12,7 @@ import { } from './core'; import { MerkleTreeOptions, defaultOptions } from './options'; +import { LeafHash, NodeHash } from './hashes'; import { validateArgument, invariant } from './utils/errors'; export interface MerkleTreeData { @@ -40,7 +41,8 @@ export abstract class MerkleTreeImpl implements MerkleTree { protected constructor( protected readonly tree: HexString[], protected readonly values: MerkleTreeData['values'], - public readonly leafHash: MerkleTree['leafHash'], + public readonly leafHash: LeafHash, + protected readonly nodeHash?: NodeHash, ) { validateArgument( values.every(({ value }) => typeof value != 'number'), @@ -52,7 +54,8 @@ export abstract class MerkleTreeImpl implements MerkleTree { protected static prepare( values: T[], options: MerkleTreeOptions = {}, - leafHash: MerkleTree['leafHash'], + leafHash: LeafHash, + nodeHash?: NodeHash, ): [tree: HexString[], indexedValues: MerkleTreeData['values']] { const sortLeaves = options.sortLeaves ?? defaultOptions.sortLeaves; const hashedValues = values.map((value, valueIndex) => ({ @@ -65,7 +68,10 @@ export abstract class MerkleTreeImpl implements MerkleTree { hashedValues.sort((a, b) => compare(a.hash, b.hash)); } - const tree = makeMerkleTree(hashedValues.map(v => v.hash)); + const tree = makeMerkleTree( + hashedValues.map(v => v.hash), + nodeHash, + ); const indexedValues = values.map(value => ({ value, treeIndex: 0 })); for (const [leafIndex, { valueIndex }] of hashedValues.entries()) { @@ -93,7 +99,7 @@ export abstract class MerkleTreeImpl implements MerkleTree { validate(): void { this.values.forEach((_, i) => this._validateValueAt(i)); - invariant(isValidMerkleTree(this.tree), 'Merkle tree is invalid'); + invariant(isValidMerkleTree(this.tree, this.nodeHash), 'Merkle tree is invalid'); } leafLookup(leaf: T): number { @@ -171,10 +177,10 @@ export abstract class MerkleTreeImpl implements MerkleTree { } private _verify(leafHash: BytesLike, proof: BytesLike[]): boolean { - return this.root === processProof(leafHash, proof); + return this.root === processProof(leafHash, proof, this.nodeHash); } private _verifyMultiProof(multiproof: MultiProof): boolean { - return this.root === processMultiProof(multiproof); + return this.root === processMultiProof(multiproof, this.nodeHash); } } diff --git a/src/simple.test.ts b/src/simple.test.ts index b5cafe2..c9811ac 100644 --- a/src/simple.test.ts +++ b/src/simple.test.ts @@ -1,23 +1,36 @@ import { test, testProp, fc } from '@fast-check/ava'; import { HashZero as zero } from '@ethersproject/constants'; +import { keccak256 } from '@ethersproject/keccak256'; import { SimpleMerkleTree } from './simple'; +import { BytesLike, HexString, concat, compare } from './bytes'; + +const reverseNodeHash = (a: BytesLike, b: BytesLike): HexString => keccak256(concat([a, b].sort(compare).reverse())); +const otherNodeHash = (a: BytesLike, b: BytesLike): HexString => keccak256(reverseNodeHash(a, b)); // double hash + import { toHex } from './bytes'; import { InvalidArgumentError, InvariantError } from './utils/errors'; const leaf = fc.uint8Array({ minLength: 32, maxLength: 32 }).map(toHex); const leaves = fc.array(leaf, { minLength: 1 }); -const options = fc.record({ sortLeaves: fc.oneof(fc.constant(undefined), fc.boolean()) }); +const options = fc.record({ + sortLeaves: fc.oneof(fc.constant(undefined), fc.boolean()), + nodeHash: fc.oneof(fc.constant(undefined), fc.constant(reverseNodeHash)), +}); -const tree = fc.tuple(leaves, options).map(([leaves, options]) => SimpleMerkleTree.of(leaves, options)); +const tree = fc + .tuple(leaves, options) + .chain(([leaves, options]) => fc.tuple(fc.constant(SimpleMerkleTree.of(leaves, options)), fc.constant(options))); const treeAndLeaf = fc.tuple(leaves, options).chain(([leaves, options]) => fc.tuple( fc.constant(SimpleMerkleTree.of(leaves, options)), + fc.constant(options), fc.nat({ max: leaves.length - 1 }).map(index => ({ value: leaves[index]!, index })), ), ); const treeAndLeaves = fc.tuple(leaves, options).chain(([leaves, options]) => fc.tuple( fc.constant(SimpleMerkleTree.of(leaves, options)), + fc.constant(options), fc .uniqueArray(fc.nat({ max: leaves.length - 1 })) .map(indices => indices.map(index => ({ value: leaves[index]!, index }))), @@ -26,41 +39,55 @@ const treeAndLeaves = fc.tuple(leaves, options).chain(([leaves, options]) => fc.configureGlobal({ numRuns: process.env.CI ? 10000 : 100 }); -testProp('generates a valid tree', [tree], (t, tree) => { +testProp('generates a valid tree', [tree], (t, [tree]) => { t.notThrows(() => tree.validate()); }); -testProp('generates valid single proofs for all leaves', [treeAndLeaf], (t, [tree, { value: leaf, index }]) => { - const proof1 = tree.getProof(index); - const proof2 = tree.getProof(leaf); - - t.deepEqual(proof1, proof2); - t.true(tree.verify(index, proof1)); - t.true(tree.verify(leaf, proof1)); - t.true(SimpleMerkleTree.verify(tree.root, leaf, proof1)); -}); +testProp( + 'generates valid single proofs for all leaves', + [treeAndLeaf], + (t, [tree, options, { value: leaf, index }]) => { + const proof1 = tree.getProof(index); + const proof2 = tree.getProof(leaf); + + t.deepEqual(proof1, proof2); + t.true(tree.verify(index, proof1)); + t.true(tree.verify(leaf, proof1)); + t.true(SimpleMerkleTree.verify(tree.root, leaf, proof1, options.nodeHash)); + }, +); -testProp('rejects invalid proofs', [treeAndLeaf, tree], (t, [tree, { value: leaf }], otherTree) => { - const proof = tree.getProof(leaf); - t.false(otherTree.verify(leaf, proof)); - t.false(SimpleMerkleTree.verify(otherTree.root, leaf, proof)); -}); +testProp( + 'rejects invalid proofs', + [treeAndLeaf, tree], + (t, [tree, options, { value: leaf }], [otherTree, otherOptions]) => { + const proof = tree.getProof(leaf); + t.false(otherTree.verify(leaf, proof)); + t.false(SimpleMerkleTree.verify(otherTree.root, leaf, proof, options.nodeHash)); + t.false(SimpleMerkleTree.verify(otherTree.root, leaf, proof, otherOptions.nodeHash)); + }, +); -testProp('generates valid multiproofs', [treeAndLeaves], (t, [tree, indices]) => { +testProp('generates valid multiproofs', [treeAndLeaves], (t, [tree, options, indices]) => { const proof1 = tree.getMultiProof(indices.map(e => e.index)); const proof2 = tree.getMultiProof(indices.map(e => e.value)); t.deepEqual(proof1, proof2); t.true(tree.verifyMultiProof(proof1)); - t.true(SimpleMerkleTree.verifyMultiProof(tree.root, proof1)); + t.true(SimpleMerkleTree.verifyMultiProof(tree.root, proof1, options.nodeHash)); }); -testProp('rejects invalid multiproofs', [treeAndLeaves, tree], (t, [tree, indices], otherTree) => { - const multiProof = tree.getMultiProof(indices.map(e => e.index)); - - t.false(otherTree.verifyMultiProof(multiProof)); - t.false(SimpleMerkleTree.verifyMultiProof(otherTree.root, multiProof)); -}); +testProp( + 'rejects invalid multiproofs', + [treeAndLeaves, tree], + (t, [tree, options, indices], [otherTree, otherOptions]) => { + const multiProof = tree.getMultiProof(indices.map(e => e.index)); + + t.false(otherTree.verifyMultiProof(multiProof)); + t.false(SimpleMerkleTree.verifyMultiProof(otherTree.root, multiProof, options.nodeHash)); + t.false(SimpleMerkleTree.verifyMultiProof(otherTree.root, multiProof, otherOptions.nodeHash)); + }, +); testProp( 'renders tree representation', @@ -68,6 +95,8 @@ testProp( (t, leaves) => { t.snapshot(SimpleMerkleTree.of(leaves, { sortLeaves: true }).render()); t.snapshot(SimpleMerkleTree.of(leaves, { sortLeaves: false }).render()); + t.snapshot(SimpleMerkleTree.of(leaves, { sortLeaves: true, nodeHash: reverseNodeHash }).render()); + t.snapshot(SimpleMerkleTree.of(leaves, { sortLeaves: false, nodeHash: reverseNodeHash }).render()); }, { numRuns: 1, seed: 0 }, ); @@ -78,24 +107,34 @@ testProp( (t, leaves) => { t.snapshot(SimpleMerkleTree.of(leaves, { sortLeaves: true }).dump()); t.snapshot(SimpleMerkleTree.of(leaves, { sortLeaves: false }).dump()); + t.snapshot(SimpleMerkleTree.of(leaves, { sortLeaves: true, nodeHash: reverseNodeHash }).dump()); + t.snapshot(SimpleMerkleTree.of(leaves, { sortLeaves: false, nodeHash: reverseNodeHash }).dump()); }, { numRuns: 1, seed: 0 }, ); -testProp('dump and load', [tree], (t, tree) => { - const recoveredTree = SimpleMerkleTree.load(tree.dump()); - recoveredTree.validate(); +testProp('dump and load', [tree], (t, [tree, options]) => { + const dump = tree.dump(); + const recoveredTree = SimpleMerkleTree.load(dump, options.nodeHash); + recoveredTree.validate(); // already done in load + t.is(dump.hash, options.nodeHash ? 'custom' : undefined); t.is(tree.root, recoveredTree.root); t.is(tree.render(), recoveredTree.render()); t.deepEqual(tree.entries(), recoveredTree.entries()); t.deepEqual(tree.dump(), recoveredTree.dump()); }); -testProp('reject out of bounds value index', [tree], (t, tree) => { +testProp('reject out of bounds value index', [tree], (t, [tree]) => { t.throws(() => tree.getProof(-1), new InvalidArgumentError('Index out of bounds')); }); +// We need at least 2 leaves for internal node hashing to come into play +testProp('reject loading dump with wrong node hash', [fc.array(leaf, { minLength: 2 })], (t, leaves) => { + const dump = SimpleMerkleTree.of(leaves, { nodeHash: reverseNodeHash }).dump(); + t.throws(() => SimpleMerkleTree.load(dump, otherNodeHash), new InvariantError('Merkle tree is invalid')); +}); + test('reject invalid leaf size', t => { const invalidLeaf = '0x000000000000000000000000000000000000000000000000000000000000000000'; t.throws(() => SimpleMerkleTree.of([invalidLeaf]), { @@ -116,22 +155,28 @@ test('reject unrecognized tree dump', t => { }); test('reject malformed tree dump', t => { - const loadedTree1 = SimpleMerkleTree.load({ - format: 'simple-v1', - tree: [zero], - values: [ - { - value: '0x0000000000000000000000000000000000000000000000000000000000000001', - treeIndex: 0, - }, - ], - }); - t.throws(() => loadedTree1.getProof(0), new InvariantError('Merkle tree does not contain the expected value')); + t.throws( + () => + SimpleMerkleTree.load({ + format: 'simple-v1', + tree: [zero], + values: [ + { + value: '0x0000000000000000000000000000000000000000000000000000000000000001', + treeIndex: 0, + }, + ], + }), + new InvariantError('Merkle tree does not contain the expected value'), + ); - const loadedTree2 = SimpleMerkleTree.load({ - format: 'simple-v1', - tree: [zero, zero, zero], - values: [{ value: zero, treeIndex: 2 }], - }); - t.throws(() => loadedTree2.getProof(0), new InvariantError('Unable to prove value')); + t.throws( + () => + SimpleMerkleTree.load({ + format: 'simple-v1', + tree: [zero, zero, zero], + values: [{ value: zero, treeIndex: 2 }], + }), + new InvariantError('Merkle tree is invalid'), + ); }); diff --git a/src/simple.test.ts.md b/src/simple.test.ts.md index 2f47b43..ec437f4 100644 --- a/src/simple.test.ts.md +++ b/src/simple.test.ts.md @@ -58,6 +58,58 @@ Generated by [AVA](https://avajs.dev). ├─ 13) 0xf26f6ac933beb3e3608203578396131902729b62338aae6b4e1a767ae30c8b76␊ └─ 14) 0x223dc038a27b04f90f865157aefbee4e661bdcf6a35cf443f57daae5e6eb3e10` +> Snapshot 3 + + `0) 0xd37427b3c449bf13d7e7d79cf805d2c0cabc7d23f7af86e8c24342a65a38117f␊ + ├─ 1) 0x23ef2b407ee96466ab7c1a264a943f0c6472fea32e2b954988e875cec381fc37␊ + │ ├─ 3) 0x3c80438df7d87a81d85c2cc336c339bf6eb1b7a4de3f40d1049a69df5ad27508␊ + │ │ ├─ 7) 0x3424aa6830d46cca88e092afbf281f0ee77e434130ee44d01427d47b31a43a85␊ + │ │ │ ├─ 15) 0x9f71c4166a5f935a4bb399392a0e19c8f6f6d70f9a57a5f49b76fd723b694795␊ + │ │ │ └─ 16) 0x8c11d27a6e9c74765e13271dc8aeae29070438ee6ee872b94347de321c114657␊ + │ │ └─ 8) 0x1b9b2b9ef84eba851dafa78967746275701a1cc21a36a83b99595b44a197da6f␊ + │ │ ├─ 17) 0x88a73a1171ee3428809bb0a61a2e3c5d1cbf719910d939ae243ec2859fdad36a␊ + │ │ └─ 18) 0x46cfb9f0d1f86847e17e8975657bb314b1a3b6bb89e4a55f7d6f4c2fdf0e5422␊ + │ └─ 4) 0x3b1d26649340543d5b5a6155c84e67f44fa55809b0f362f66bf2ade281a07ea5␊ + │ ├─ 9) 0x18650562a2744d49344010252b3e3104da144aa6737c4e730d59712909631fc7␊ + │ │ ├─ 19) 0x2fac6f51addc8d004b3ff871d87c51837a4d881adb95b2a27820dbcafa94e7b8␊ + │ │ └─ 20) 0x223dc038a27b04f90f865157aefbee4e661bdcf6a35cf443f57daae5e6eb3e10␊ + │ └─ 10) 0x1faef214093f94529d3d8238dc6597ca6068c9d22a6ecfd3f7ccc25ed7b726df␊ + │ ├─ 21) 0x1d3d2f27573475675df2df1fb427256b05d4fb3c1b60baa7f58e479719daa0aa␊ + │ └─ 22) 0x0229437bdff01e90d3cd2c5dc9d3253d2c13bcb59c0b1caf0862bac2bd829db1␊ + └─ 2) 0x28b144b0df394fbee7f318e0d0747f5b20cfa05fe2b6aa20437a9c4fc1c65ceb␊ + ├─ 5) 0xcb4307038b5d39b0653ac9fb0bba4a43601307bc4844b72694dde953435db74d␊ + │ ├─ 11) 0xf26f6ac933beb3e3608203578396131902729b62338aae6b4e1a767ae30c8b76␊ + │ └─ 12) 0xe6c6ce5eb372e601197002c965ee5d050cb2925dbb89822c5c8f27585a9b5a96␊ + └─ 6) 0xdcaac046ba8c026de89bdcaeb2d8b5ddf762ec899f86060a802a00371fd7d4f2␊ + ├─ 13) 0xc36697399f86481780854e17b7cbc50072efd74da5bfe81a712db493185c82ec␊ + └─ 14) 0xaa504056e189fd775322d63793b5a16fa1207441aa1a32439deb249fcdf54c3b` + +> Snapshot 4 + + `0) 0xfdcc17bd4cc4ebba0cc671d3c52b68cd13e695f9dc3c4e7e4c8faf7ce0f8e371␊ + ├─ 1) 0x4a8b703c889b2fd6cceb57d51a2960c4c7c283707f8d8080d93d08ad9dea922e␊ + │ ├─ 3) 0x5dc10e6d5380d7c6dc6f5f042eda9974cbb412236f79686e40a052fabc12c049␊ + │ │ ├─ 7) 0x5d23ab436576466a1fc3cd310e096e1cade67473d398c6a43c9952ff31e1e75a␊ + │ │ │ ├─ 15) 0x9f71c4166a5f935a4bb399392a0e19c8f6f6d70f9a57a5f49b76fd723b694795␊ + │ │ │ └─ 16) 0xe6c6ce5eb372e601197002c965ee5d050cb2925dbb89822c5c8f27585a9b5a96␊ + │ │ └─ 8) 0xa1d2e032a606b18bbb030451c6664ea03e4ab569f7318aa126a2a0002b1178b2␊ + │ │ ├─ 17) 0xaa504056e189fd775322d63793b5a16fa1207441aa1a32439deb249fcdf54c3b␊ + │ │ └─ 18) 0x88a73a1171ee3428809bb0a61a2e3c5d1cbf719910d939ae243ec2859fdad36a␊ + │ └─ 4) 0xd9eaefae907251e252eb2ed5c8963cb568aef567615ea3e006233374997eba5a␊ + │ ├─ 9) 0x553394a4ded1e4b00817083b9fdeecd889ea3b7d9c19046afcf1e4eb9199d55d␊ + │ │ ├─ 19) 0x8c11d27a6e9c74765e13271dc8aeae29070438ee6ee872b94347de321c114657␊ + │ │ └─ 20) 0x0229437bdff01e90d3cd2c5dc9d3253d2c13bcb59c0b1caf0862bac2bd829db1␊ + │ └─ 10) 0xdc812b2fa70fff382e84e45bee14d08893c2ff6279019ee95b04ce76be6f95f8␊ + │ ├─ 21) 0x46cfb9f0d1f86847e17e8975657bb314b1a3b6bb89e4a55f7d6f4c2fdf0e5422␊ + │ └─ 22) 0x1d3d2f27573475675df2df1fb427256b05d4fb3c1b60baa7f58e479719daa0aa␊ + └─ 2) 0xf31eacd3933ab8ffac3fe2218dd35f33773df8b0b883d4466421c60b2c5b499d␊ + ├─ 5) 0xe2728c3f6cfeeed85f6b1276ac1076feafaa56464605e0fdeb3a189ed02e77e3␊ + │ ├─ 11) 0xc36697399f86481780854e17b7cbc50072efd74da5bfe81a712db493185c82ec␊ + │ └─ 12) 0x2fac6f51addc8d004b3ff871d87c51837a4d881adb95b2a27820dbcafa94e7b8␊ + └─ 6) 0x08e34c58c9ac0aba2ad2e76375558139169fbc57883d8895841499566b41df21␊ + ├─ 13) 0xf26f6ac933beb3e3608203578396131902729b62338aae6b4e1a767ae30c8b76␊ + └─ 14) 0x223dc038a27b04f90f865157aefbee4e661bdcf6a35cf443f57daae5e6eb3e10` + ## dump (with seed=0) > Snapshot 1 @@ -221,3 +273,167 @@ Generated by [AVA](https://avajs.dev). }, ], } + +> Snapshot 3 + + { + format: 'simple-v1', + hash: 'custom', + tree: [ + '0xd37427b3c449bf13d7e7d79cf805d2c0cabc7d23f7af86e8c24342a65a38117f', + '0x23ef2b407ee96466ab7c1a264a943f0c6472fea32e2b954988e875cec381fc37', + '0x28b144b0df394fbee7f318e0d0747f5b20cfa05fe2b6aa20437a9c4fc1c65ceb', + '0x3c80438df7d87a81d85c2cc336c339bf6eb1b7a4de3f40d1049a69df5ad27508', + '0x3b1d26649340543d5b5a6155c84e67f44fa55809b0f362f66bf2ade281a07ea5', + '0xcb4307038b5d39b0653ac9fb0bba4a43601307bc4844b72694dde953435db74d', + '0xdcaac046ba8c026de89bdcaeb2d8b5ddf762ec899f86060a802a00371fd7d4f2', + '0x3424aa6830d46cca88e092afbf281f0ee77e434130ee44d01427d47b31a43a85', + '0x1b9b2b9ef84eba851dafa78967746275701a1cc21a36a83b99595b44a197da6f', + '0x18650562a2744d49344010252b3e3104da144aa6737c4e730d59712909631fc7', + '0x1faef214093f94529d3d8238dc6597ca6068c9d22a6ecfd3f7ccc25ed7b726df', + '0xf26f6ac933beb3e3608203578396131902729b62338aae6b4e1a767ae30c8b76', + '0xe6c6ce5eb372e601197002c965ee5d050cb2925dbb89822c5c8f27585a9b5a96', + '0xc36697399f86481780854e17b7cbc50072efd74da5bfe81a712db493185c82ec', + '0xaa504056e189fd775322d63793b5a16fa1207441aa1a32439deb249fcdf54c3b', + '0x9f71c4166a5f935a4bb399392a0e19c8f6f6d70f9a57a5f49b76fd723b694795', + '0x8c11d27a6e9c74765e13271dc8aeae29070438ee6ee872b94347de321c114657', + '0x88a73a1171ee3428809bb0a61a2e3c5d1cbf719910d939ae243ec2859fdad36a', + '0x46cfb9f0d1f86847e17e8975657bb314b1a3b6bb89e4a55f7d6f4c2fdf0e5422', + '0x2fac6f51addc8d004b3ff871d87c51837a4d881adb95b2a27820dbcafa94e7b8', + '0x223dc038a27b04f90f865157aefbee4e661bdcf6a35cf443f57daae5e6eb3e10', + '0x1d3d2f27573475675df2df1fb427256b05d4fb3c1b60baa7f58e479719daa0aa', + '0x0229437bdff01e90d3cd2c5dc9d3253d2c13bcb59c0b1caf0862bac2bd829db1', + ], + values: [ + { + treeIndex: 21, + value: '0x1d3d2f27573475675df2df1fb427256b05d4fb3c1b60baa7f58e479719daa0aa', + }, + { + treeIndex: 18, + value: '0x46cfb9f0d1f86847e17e8975657bb314b1a3b6bb89e4a55f7d6f4c2fdf0e5422', + }, + { + treeIndex: 22, + value: '0x0229437bdff01e90d3cd2c5dc9d3253d2c13bcb59c0b1caf0862bac2bd829db1', + }, + { + treeIndex: 16, + value: '0x8c11d27a6e9c74765e13271dc8aeae29070438ee6ee872b94347de321c114657', + }, + { + treeIndex: 17, + value: '0x88a73a1171ee3428809bb0a61a2e3c5d1cbf719910d939ae243ec2859fdad36a', + }, + { + treeIndex: 14, + value: '0xaa504056e189fd775322d63793b5a16fa1207441aa1a32439deb249fcdf54c3b', + }, + { + treeIndex: 12, + value: '0xe6c6ce5eb372e601197002c965ee5d050cb2925dbb89822c5c8f27585a9b5a96', + }, + { + treeIndex: 15, + value: '0x9f71c4166a5f935a4bb399392a0e19c8f6f6d70f9a57a5f49b76fd723b694795', + }, + { + treeIndex: 20, + value: '0x223dc038a27b04f90f865157aefbee4e661bdcf6a35cf443f57daae5e6eb3e10', + }, + { + treeIndex: 11, + value: '0xf26f6ac933beb3e3608203578396131902729b62338aae6b4e1a767ae30c8b76', + }, + { + treeIndex: 19, + value: '0x2fac6f51addc8d004b3ff871d87c51837a4d881adb95b2a27820dbcafa94e7b8', + }, + { + treeIndex: 13, + value: '0xc36697399f86481780854e17b7cbc50072efd74da5bfe81a712db493185c82ec', + }, + ], + } + +> Snapshot 4 + + { + format: 'simple-v1', + hash: 'custom', + tree: [ + '0xfdcc17bd4cc4ebba0cc671d3c52b68cd13e695f9dc3c4e7e4c8faf7ce0f8e371', + '0x4a8b703c889b2fd6cceb57d51a2960c4c7c283707f8d8080d93d08ad9dea922e', + '0xf31eacd3933ab8ffac3fe2218dd35f33773df8b0b883d4466421c60b2c5b499d', + '0x5dc10e6d5380d7c6dc6f5f042eda9974cbb412236f79686e40a052fabc12c049', + '0xd9eaefae907251e252eb2ed5c8963cb568aef567615ea3e006233374997eba5a', + '0xe2728c3f6cfeeed85f6b1276ac1076feafaa56464605e0fdeb3a189ed02e77e3', + '0x08e34c58c9ac0aba2ad2e76375558139169fbc57883d8895841499566b41df21', + '0x5d23ab436576466a1fc3cd310e096e1cade67473d398c6a43c9952ff31e1e75a', + '0xa1d2e032a606b18bbb030451c6664ea03e4ab569f7318aa126a2a0002b1178b2', + '0x553394a4ded1e4b00817083b9fdeecd889ea3b7d9c19046afcf1e4eb9199d55d', + '0xdc812b2fa70fff382e84e45bee14d08893c2ff6279019ee95b04ce76be6f95f8', + '0xc36697399f86481780854e17b7cbc50072efd74da5bfe81a712db493185c82ec', + '0x2fac6f51addc8d004b3ff871d87c51837a4d881adb95b2a27820dbcafa94e7b8', + '0xf26f6ac933beb3e3608203578396131902729b62338aae6b4e1a767ae30c8b76', + '0x223dc038a27b04f90f865157aefbee4e661bdcf6a35cf443f57daae5e6eb3e10', + '0x9f71c4166a5f935a4bb399392a0e19c8f6f6d70f9a57a5f49b76fd723b694795', + '0xe6c6ce5eb372e601197002c965ee5d050cb2925dbb89822c5c8f27585a9b5a96', + '0xaa504056e189fd775322d63793b5a16fa1207441aa1a32439deb249fcdf54c3b', + '0x88a73a1171ee3428809bb0a61a2e3c5d1cbf719910d939ae243ec2859fdad36a', + '0x8c11d27a6e9c74765e13271dc8aeae29070438ee6ee872b94347de321c114657', + '0x0229437bdff01e90d3cd2c5dc9d3253d2c13bcb59c0b1caf0862bac2bd829db1', + '0x46cfb9f0d1f86847e17e8975657bb314b1a3b6bb89e4a55f7d6f4c2fdf0e5422', + '0x1d3d2f27573475675df2df1fb427256b05d4fb3c1b60baa7f58e479719daa0aa', + ], + values: [ + { + treeIndex: 22, + value: '0x1d3d2f27573475675df2df1fb427256b05d4fb3c1b60baa7f58e479719daa0aa', + }, + { + treeIndex: 21, + value: '0x46cfb9f0d1f86847e17e8975657bb314b1a3b6bb89e4a55f7d6f4c2fdf0e5422', + }, + { + treeIndex: 20, + value: '0x0229437bdff01e90d3cd2c5dc9d3253d2c13bcb59c0b1caf0862bac2bd829db1', + }, + { + treeIndex: 19, + value: '0x8c11d27a6e9c74765e13271dc8aeae29070438ee6ee872b94347de321c114657', + }, + { + treeIndex: 18, + value: '0x88a73a1171ee3428809bb0a61a2e3c5d1cbf719910d939ae243ec2859fdad36a', + }, + { + treeIndex: 17, + value: '0xaa504056e189fd775322d63793b5a16fa1207441aa1a32439deb249fcdf54c3b', + }, + { + treeIndex: 16, + value: '0xe6c6ce5eb372e601197002c965ee5d050cb2925dbb89822c5c8f27585a9b5a96', + }, + { + treeIndex: 15, + value: '0x9f71c4166a5f935a4bb399392a0e19c8f6f6d70f9a57a5f49b76fd723b694795', + }, + { + treeIndex: 14, + value: '0x223dc038a27b04f90f865157aefbee4e661bdcf6a35cf443f57daae5e6eb3e10', + }, + { + treeIndex: 13, + value: '0xf26f6ac933beb3e3608203578396131902729b62338aae6b4e1a767ae30c8b76', + }, + { + treeIndex: 12, + value: '0x2fac6f51addc8d004b3ff871d87c51837a4d881adb95b2a27820dbcafa94e7b8', + }, + { + treeIndex: 11, + value: '0xc36697399f86481780854e17b7cbc50072efd74da5bfe81a712db493185c82ec', + }, + ], + } diff --git a/src/simple.test.ts.snap b/src/simple.test.ts.snap index 5c19c98..9383c9f 100644 Binary files a/src/simple.test.ts.snap and b/src/simple.test.ts.snap differ diff --git a/src/simple.ts b/src/simple.ts index 94f87e8..ff1d550 100644 --- a/src/simple.ts +++ b/src/simple.ts @@ -3,10 +3,16 @@ import { BytesLike, HexString, toHex } from './bytes'; import { MultiProof, processProof, processMultiProof } from './core'; import { MerkleTreeData, MerkleTreeImpl } from './merkletree'; import { MerkleTreeOptions } from './options'; +import { NodeHash } from './hashes'; import { validateArgument } from './utils/errors'; export interface SimpleMerkleTreeData extends MerkleTreeData { format: 'simple-v1'; + hash?: 'custom'; +} + +export interface SimpleMerkleTreeOptions extends MerkleTreeOptions { + nodeHash?: NodeHash; } export function formatLeaf(value: BytesLike): HexString { @@ -14,22 +20,29 @@ export function formatLeaf(value: BytesLike): HexString { } export class SimpleMerkleTree extends MerkleTreeImpl { - static of(values: BytesLike[], options: MerkleTreeOptions = {}): SimpleMerkleTree { - const [tree, indexedValues] = MerkleTreeImpl.prepare(values, options, formatLeaf); - return new SimpleMerkleTree(tree, indexedValues, formatLeaf); + static of(values: BytesLike[], options: SimpleMerkleTreeOptions = {}): SimpleMerkleTree { + const [tree, indexedValues] = MerkleTreeImpl.prepare(values, options, formatLeaf, options.nodeHash); + return new SimpleMerkleTree(tree, indexedValues, formatLeaf, options.nodeHash); } - static load(data: SimpleMerkleTreeData): SimpleMerkleTree { + static load(data: SimpleMerkleTreeData, nodeHash?: NodeHash): SimpleMerkleTree { validateArgument(data.format === 'simple-v1', `Unknown format '${data.format}'`); - return new SimpleMerkleTree(data.tree, data.values, formatLeaf); + validateArgument( + (nodeHash == undefined) !== (data.hash == 'custom'), + nodeHash ? 'Data does not expect a custom node hashing function' : 'Data expects a custom node hashing function', + ); + + const tree = new SimpleMerkleTree(data.tree, data.values, formatLeaf, nodeHash); + tree.validate(); + return tree; } - static verify(root: BytesLike, leaf: BytesLike, proof: BytesLike[]): boolean { - return toHex(root) === processProof(formatLeaf(leaf), proof); + static verify(root: BytesLike, leaf: BytesLike, proof: BytesLike[], nodeHash?: NodeHash): boolean { + return toHex(root) === processProof(formatLeaf(leaf), proof, nodeHash); } - static verifyMultiProof(root: BytesLike, multiproof: MultiProof): boolean { - return toHex(root) === processMultiProof(multiproof); + static verifyMultiProof(root: BytesLike, multiproof: MultiProof, nodeHash?: NodeHash): boolean { + return toHex(root) === processMultiProof(multiproof, nodeHash); } dump(): SimpleMerkleTreeData { @@ -37,6 +50,7 @@ export class SimpleMerkleTree extends MerkleTreeImpl { format: 'simple-v1', tree: this.tree, values: this.values.map(({ value, treeIndex }) => ({ value: toHex(value), treeIndex })), + ...(this.nodeHash ? { hash: 'custom' } : {}), }; } } diff --git a/src/standard.test.ts b/src/standard.test.ts index 2d622d4..164f3e2 100644 --- a/src/standard.test.ts +++ b/src/standard.test.ts @@ -85,7 +85,7 @@ testProp( testProp('dump and load', [tree], (t, tree) => { const recoveredTree = StandardMerkleTree.load(tree.dump()); - recoveredTree.validate(); + recoveredTree.validate(); // already done in load t.is(tree.root, recoveredTree.root); t.is(tree.render(), recoveredTree.render()); @@ -110,19 +110,25 @@ test('reject unrecognized tree dump', t => { }); test('reject malformed tree dump', t => { - const loadedTree1 = StandardMerkleTree.load({ - format: 'standard-v1', - tree: [zero], - values: [{ value: ['0'], treeIndex: 0 }], - leafEncoding: ['uint256'], - }); - t.throws(() => loadedTree1.getProof(0), new InvariantError('Merkle tree does not contain the expected value')); - - const loadedTree2 = StandardMerkleTree.load({ - format: 'standard-v1', - tree: [zero, zero, keccak256(keccak256(zero))], - values: [{ value: ['0'], treeIndex: 2 }], - leafEncoding: ['uint256'], - }); - t.throws(() => loadedTree2.getProof(0), new InvariantError('Unable to prove value')); + t.throws( + () => + StandardMerkleTree.load({ + format: 'standard-v1', + tree: [zero], + values: [{ value: ['0'], treeIndex: 0 }], + leafEncoding: ['uint256'], + }), + new InvariantError('Merkle tree does not contain the expected value'), + ); + + t.throws( + () => + StandardMerkleTree.load({ + format: 'standard-v1', + tree: [zero, zero, keccak256(keccak256(zero))], + values: [{ value: ['0'], treeIndex: 2 }], + leafEncoding: ['uint256'], + }), + new InvariantError('Merkle tree is invalid'), + ); }); diff --git a/src/standard.ts b/src/standard.ts index e15ef77..c69488d 100644 --- a/src/standard.ts +++ b/src/standard.ts @@ -1,9 +1,8 @@ -import { keccak256 } from '@ethersproject/keccak256'; -import { defaultAbiCoder } from '@ethersproject/abi'; import { BytesLike, HexString, toHex } from './bytes'; import { MultiProof, processProof, processMultiProof } from './core'; import { MerkleTreeData, MerkleTreeImpl } from './merkletree'; import { MerkleTreeOptions } from './options'; +import { standardLeafHash } from './hashes'; import { validateArgument } from './utils/errors'; export interface StandardMerkleTreeData extends MerkleTreeData { @@ -11,10 +10,6 @@ export interface StandardMerkleTreeData extends MerkleTreeData< leafEncoding: string[]; } -export function standardLeafHash(types: string[], value: T): HexString { - return keccak256(keccak256(defaultAbiCoder.encode(types, value))); -} - export class StandardMerkleTree extends MerkleTreeImpl { protected constructor( protected readonly tree: HexString[], @@ -29,6 +24,7 @@ export class StandardMerkleTree extends MerkleTreeImpl { leafEncoding: string[], options: MerkleTreeOptions = {}, ): StandardMerkleTree { + // use default nodeHash (standardNodeHash) const [tree, indexedValues] = MerkleTreeImpl.prepare(values, options, leaf => standardLeafHash(leafEncoding, leaf)); return new StandardMerkleTree(tree, indexedValues, leafEncoding); } @@ -36,10 +32,14 @@ export class StandardMerkleTree extends MerkleTreeImpl { static load(data: StandardMerkleTreeData): StandardMerkleTree { validateArgument(data.format === 'standard-v1', `Unknown format '${data.format}'`); validateArgument(data.leafEncoding !== undefined, 'Expected leaf encoding'); - return new StandardMerkleTree(data.tree, data.values, data.leafEncoding); + + const tree = new StandardMerkleTree(data.tree, data.values, data.leafEncoding); + tree.validate(); + return tree; } static verify(root: BytesLike, leafEncoding: string[], leaf: T, proof: BytesLike[]): boolean { + // use default nodeHash (standardNodeHash) for processProof return toHex(root) === processProof(standardLeafHash(leafEncoding, leaf), proof); } @@ -48,6 +48,7 @@ export class StandardMerkleTree extends MerkleTreeImpl { leafEncoding: string[], multiproof: MultiProof, ): boolean { + // use default nodeHash (standardNodeHash) for processMultiProof return ( toHex(root) === processMultiProof({