Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SimpleMerkleTree #36

Merged
merged 38 commits into from
Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
302fa2d
Simple MerkleTree alternative
ernestognw Feb 23, 2024
f24e205
wip
Amxx Feb 23, 2024
30d150c
Use generic MerkleTreeImpl
Amxx Feb 23, 2024
21399c1
MerkleTree interface
Amxx Feb 23, 2024
eabc28e
explicit type
Amxx Feb 23, 2024
6eaf646
use bind instead of curation
Amxx Feb 23, 2024
d9678a6
fix default
Amxx Feb 23, 2024
034ce88
newline
Amxx Feb 23, 2024
2da68b1
add prettier (config ported from @openzeppelin/contracts)
Amxx Feb 23, 2024
b2d34fc
more generic multiproof: input support all BytesLike, output is expli…
Amxx Feb 24, 2024
bcfc765
Merge branch 'master' into support-raw-leaves-inheritance
Amxx Feb 24, 2024
62ab129
remove empty file
Amxx Feb 24, 2024
ec339e1
Apply PR suggestions
ernestognw Feb 25, 2024
826ffab
Unify all error interfaces
ernestognw Feb 25, 2024
ada28f1
Unify errors
ernestognw Feb 25, 2024
b73e3c4
fix naming
Amxx Feb 26, 2024
72057f3
Update src/merkletree.ts
Amxx Feb 26, 2024
de39370
rename leafHasher to leafHash, and make mark it as public
Amxx Feb 26, 2024
1be7ece
rewrite checks
Amxx Feb 26, 2024
5e69b25
remove duplicate test
Amxx Feb 26, 2024
de68246
simplify
Amxx Feb 26, 2024
e13943e
naming consistency
Amxx Feb 26, 2024
e5e035e
refactor
Amxx Feb 26, 2024
64b7dfa
split value validation and leafHashing
Amxx Feb 26, 2024
5d5deb8
deduplicate type definition
Amxx Feb 26, 2024
2ce3d55
specify type
Amxx Feb 26, 2024
1ad4309
fix lint
Amxx Feb 26, 2024
b13748a
refactor
Amxx Feb 26, 2024
fd1cf28
Nits
ernestognw Feb 26, 2024
a87db4c
Merge branch 'master' into support-raw-leaves-inheritance
Amxx Feb 26, 2024
d2b6e97
Add dumps
Amxx Feb 26, 2024
84e96d5
fix name
Amxx Feb 26, 2024
640e378
remove duplicate
Amxx Feb 26, 2024
6578293
up
Amxx Feb 26, 2024
1b587e8
Exclude number type support from MerkleTree
ernestognw Feb 27, 2024
a0e8b20
Address comments from @frangio
Amxx Feb 27, 2024
f48455e
fix lint
Amxx Feb 27, 2024
5d1ceb7
types → interfaces
Amxx Feb 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 1.0.6

- Added an option to disable leaf sorting.
- Added `SimpleMerkleTree` class that supports `bytes32` leaves with no extra hashing.

## 1.0.5

Expand Down
1,737 changes: 172 additions & 1,565 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
"license": "MIT",
"dependencies": {
"@ethersproject/abi": "^5.7.0",
"ethereum-cryptography": "^1.1.2"
"@ethersproject/bytes": "^5.7.0",
"@ethersproject/constants": "^5.7.0",
"@ethersproject/keccak256": "^5.7.0"
},
"devDependencies": {
"@types/mocha": "^10.0.0",
Expand Down
23 changes: 8 additions & 15 deletions src/bytes.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
import { bytesToHex } from 'ethereum-cryptography/utils';
import type { BytesLike } from '@ethersproject/bytes';
type HexString = string;

export type Bytes = Uint8Array;
import { arrayify as toBytes, hexlify as toHex, concat } from '@ethersproject/bytes';

export function compareBytes(a: Bytes, b: Bytes): number {
const n = Math.min(a.length, b.length);

for (let i = 0; i < n; i++) {
if (a[i] !== b[i]) {
return a[i]! - b[i]!;
}
}

return a.length - b.length;
function compare(a: BytesLike, b: BytesLike): number {
const diff = BigInt(toHex(a)) - BigInt(toHex(b));
frangio marked this conversation as resolved.
Show resolved Hide resolved
return diff > 0 ? 1 : diff < 0 ? -1 : 0;
}

export function hex(b: Bytes): string {
return '0x' + bytesToHex(b);
}
export type { HexString, BytesLike };
export { toBytes, toHex, concat, compare };
30 changes: 11 additions & 19 deletions src/core.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fc from 'fast-check';
import assert from 'assert/strict';
import { equalsBytes } from 'ethereum-cryptography/utils';
import { HashZero as zero } from '@ethersproject/constants';
import { keccak256 } from '@ethersproject/keccak256';
import {
makeMerkleTree,
getProof,
Expand All @@ -10,12 +11,9 @@ import {
isValidMerkleTree,
renderMerkleTree,
} from './core';
import { compareBytes, hex } from './bytes';
import { keccak256 } from 'ethereum-cryptography/keccak';
import { toHex, compare } from './bytes';

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(toHex);
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 }))));
Expand All @@ -33,7 +31,7 @@ describe('core properties', () => {
const proof = getProof(tree, treeIndex);
const leaf = leaves[leafIndex]!;
const impliedRoot = processProof(leaf, proof);
return equalsBytes(root, impliedRoot);
return root === impliedRoot;
}),
);
});
Expand All @@ -49,46 +47,40 @@ describe('core properties', () => {
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);
return root === impliedRoot;
}),
);
});
});

describe('core error conditions', () => {
it('zero leaves', () => {
assert.throws(() => makeMerkleTree([]), /^Error: Expected non-zero number of leaves$/);
assert.throws(() => makeMerkleTree([]), /^InvalidArgumentError: Expected non-zero number of leaves$/);
});

it('multiproof duplicate index', () => {
const tree = makeMerkleTree(new Array(2).fill(zero));
assert.throws(() => getMultiProof(tree, [1, 1]), /^Error: Cannot prove duplicated index$/);
assert.throws(() => getMultiProof(tree, [1, 1]), /^InvalidArgumentError: Cannot prove duplicated index$/);
});

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');

assert.throws(() => renderMerkleTree([]), /^Error: Expected non-zero number of nodes$/);
assert.throws(() => renderMerkleTree([]), /^InvalidArgumentError: Expected non-zero number of nodes$/);
});

it('multiproof invariants', () => {
const leaf = keccak256(Uint8Array.of(42));
const tree = makeMerkleTree([leaf, zero]);

const badMultiProof = {
leaves: [128, 129].map(n => keccak256(Uint8Array.of(n))).sort(compareBytes),
leaves: [128, 129].map(n => keccak256(Uint8Array.of(n))).sort(compare),
proof: [leaf, leaf],
proofFlags: [true, true, false],
};

assert.throws(() => processMultiProof(badMultiProof), /^Error: Broken invariant$/);
assert.throws(() => processMultiProof(badMultiProof), /^InvariantError$/);
});
});

class PrettyBytes extends Uint8Array {
[fc.toStringMethod]() {
return hex(this);
}
}
83 changes: 35 additions & 48 deletions src/core.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { keccak256 } from 'ethereum-cryptography/keccak';
import { concatBytes, bytesToHex, equalsBytes } from 'ethereum-cryptography/utils';
import { Bytes, compareBytes } from './bytes';
import { throwError } from './utils/throw-error';
import { keccak256 } from '@ethersproject/keccak256';
import { BytesLike, HexString, toHex, toBytes, concat, compare } from './bytes';
import { invariant, throwError, validateArgument } from './utils/errors';

const hashPair = (a: Bytes, b: Bytes) => keccak256(concatBytes(...[a, b].sort(compareBytes)));
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;
Expand All @@ -13,26 +12,21 @@ const siblingIndex = (i: number) => (i > 0 ? i - (-1) ** (i % 2) : throwError('R
const isTreeNode = (tree: unknown[], i: number) => i >= 0 && i < tree.length;
const isInternalNode = (tree: unknown[], i: number) => isTreeNode(tree, leftChildIndex(i));
const isLeafNode = (tree: unknown[], i: number) => isTreeNode(tree, i) && !isInternalNode(tree, i);
const isValidMerkleNode = (node: Bytes) => node instanceof Uint8Array && node.length === 32;
const isValidMerkleNode = (node: BytesLike) => toBytes(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) =>
const checkValidMerkleNode = (node: BytesLike) =>
void (isValidMerkleNode(node) || throwError('Merkle tree nodes must be Uint8Array of length 32'));

export function makeMerkleTree(leaves: Bytes[]): Bytes[] {
export function makeMerkleTree(leaves: BytesLike[]): HexString[] {
leaves.forEach(checkValidMerkleNode);

if (leaves.length === 0) {
throw new Error('Expected non-zero number of leaves');
}
validateArgument(leaves.length !== 0, 'Expected non-zero number of leaves');

const tree = new Array<Bytes>(2 * leaves.length - 1);
const tree = new Array<HexString>(2 * leaves.length - 1);

for (const [i, leaf] of leaves.entries()) {
tree[tree.length - 1 - i] = leaf;
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)]!);
Expand All @@ -41,22 +35,22 @@ export function makeMerkleTree(leaves: Bytes[]): Bytes[] {
return tree;
}

export function getProof(tree: Bytes[], index: number): Bytes[] {
export function getProof(tree: BytesLike[], index: number): HexString[] {
checkLeafNode(tree, index);

const proof = [];
while (index > 0) {
proof.push(tree[siblingIndex(index)]!);
index = parentIndex(index);
}
return proof;
return proof.map(node => toHex(node));
}
Amxx marked this conversation as resolved.
Show resolved Hide resolved

export function processProof(leaf: Bytes, proof: Bytes[]): Bytes {
export function processProof(leaf: BytesLike, proof: BytesLike[]): HexString {
checkValidMerkleNode(leaf);
proof.forEach(checkValidMerkleNode);

return proof.reduce(hashPair, leaf);
return toHex(proof.reduce(hashPair, leaf));
}

export interface MultiProof<T, L = T> {
Expand All @@ -65,13 +59,11 @@ export interface MultiProof<T, L = T> {
proofFlags: boolean[];
}

export function getMultiProof(tree: Bytes[], indices: number[]): MultiProof<Bytes> {
export function getMultiProof(tree: BytesLike[], indices: number[]): MultiProof<HexString> {
indices.forEach(i => checkLeafNode(tree, i));
indices.sort((a, b) => b - a);

if (indices.slice(1).some((i, p) => i === indices[p])) {
throw new Error('Cannot prove duplicated index');
}
validateArgument(!indices.slice(1).some((i, p) => i === indices[p]), 'Cannot prove duplicated index');

Amxx marked this conversation as resolved.
Show resolved Hide resolved
const stack = indices.concat(); // copy
const proof = [];
Expand All @@ -97,44 +89,41 @@ export function getMultiProof(tree: Bytes[], indices: number[]): MultiProof<Byte
}

return {
leaves: indices.map(i => tree[i]!),
proof,
leaves: indices.map(i => tree[i]!).map(node => toHex(node)),
proof: proof.map(node => toHex(node)),
Amxx marked this conversation as resolved.
Show resolved Hide resolved
proofFlags,
};
}

export function processMultiProof(multiproof: MultiProof<Bytes>): Bytes {
export function processMultiProof(multiproof: MultiProof<BytesLike>): HexString {
multiproof.leaves.forEach(checkValidMerkleNode);
multiproof.proof.forEach(checkValidMerkleNode);

if (multiproof.proof.length < multiproof.proofFlags.filter(b => !b).length) {
throw new Error('Invalid multiproof format');
}

if (multiproof.leaves.length + multiproof.proof.length !== multiproof.proofFlags.length + 1) {
throw new Error('Provided leaves and multiproof are not compatible');
}
validateArgument(
multiproof.proof.length >= multiproof.proofFlags.filter(b => !b).length,
'Invalid multiproof format',
);
validateArgument(
multiproof.leaves.length + multiproof.proof.length === multiproof.proofFlags.length + 1,
'Provided leaves and multiproof are not compatible',
);

const stack = multiproof.leaves.concat(); // copy
const proof = multiproof.proof.concat(); // copy

for (const flag of multiproof.proofFlags) {
const a = stack.shift();
const b = flag ? stack.shift() : proof.shift();
if (a === undefined || b === undefined) {
throw new Error('Broken invariant');
}
invariant(a !== undefined && b !== undefined);
stack.push(hashPair(a, b));
}

if (stack.length + proof.length !== 1) {
throw new Error('Broken invariant');
}
invariant(stack.length + proof.length === 1);

return stack.pop() ?? proof.shift()!;
return toHex(stack.pop() ?? proof.shift()!);
}

export function isValidMerkleTree(tree: Bytes[]): boolean {
export function isValidMerkleTree(tree: BytesLike[]): boolean {
for (const [i, node] of tree.entries()) {
if (!isValidMerkleNode(node)) {
return false;
Expand All @@ -147,18 +136,16 @@ export function isValidMerkleTree(tree: Bytes[]): boolean {
if (l < tree.length) {
return false;
}
} else if (!equalsBytes(node, hashPair(tree[l]!, tree[r]!))) {
} else if (node !== hashPair(tree[l]!, tree[r]!)) {
return false;
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
}
}

return tree.length > 0;
}

export function renderMerkleTree(tree: Bytes[]): string {
if (tree.length === 0) {
throw new Error('Expected non-zero number of nodes');
}
export function renderMerkleTree(tree: BytesLike[]): HexString {
validateArgument(tree.length !== 0, 'Expected non-zero number of nodes');

const stack: [number, number[]][] = [[0, []]];

Expand All @@ -178,7 +165,7 @@ export function renderMerkleTree(tree: Bytes[]): string {
.join('') +
i +
') ' +
bytesToHex(tree[i]!),
toHex(tree[i]!),
);

if (rightChildIndex(i) < tree.length) {
Expand Down
9 changes: 9 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import assert from 'assert/strict';
import { SimpleMerkleTree, StandardMerkleTree } from '.';

describe('index properties', () => {
it('classes are exported', () => {
assert.notEqual(SimpleMerkleTree, undefined);
assert.notEqual(StandardMerkleTree, undefined);
});
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { SimpleMerkleTree } from './simple';
export { StandardMerkleTree } from './standard';
Loading
Loading