Skip to content

Commit 952fd9f

Browse files
ernestognwAmxx
andauthored
Add SimpleMerkleTree (#36)
Co-authored-by: Hadrien Croubois <hadrien.croubois@gmail.com>
1 parent 697fb37 commit 952fd9f

24 files changed

+793
-1919
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 1.0.6
44

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

78
## 1.0.5
89

package-lock.json

Lines changed: 172 additions & 1565 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
"license": "MIT",
2525
"dependencies": {
2626
"@ethersproject/abi": "^5.7.0",
27-
"ethereum-cryptography": "^1.1.2"
27+
"@ethersproject/bytes": "^5.7.0",
28+
"@ethersproject/constants": "^5.7.0",
29+
"@ethersproject/keccak256": "^5.7.0"
2830
},
2931
"devDependencies": {
3032
"@types/mocha": "^10.0.0",

src/bytes.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,12 @@
1-
import { bytesToHex } from 'ethereum-cryptography/utils';
1+
import type { BytesLike } from '@ethersproject/bytes';
2+
type HexString = string;
23

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

5-
export function compareBytes(a: Bytes, b: Bytes): number {
6-
const n = Math.min(a.length, b.length);
7-
8-
for (let i = 0; i < n; i++) {
9-
if (a[i] !== b[i]) {
10-
return a[i]! - b[i]!;
11-
}
12-
}
13-
14-
return a.length - b.length;
6+
function compare(a: BytesLike, b: BytesLike): number {
7+
const diff = BigInt(toHex(a)) - BigInt(toHex(b));
8+
return diff > 0 ? 1 : diff < 0 ? -1 : 0;
159
}
1610

17-
export function hex(b: Bytes): string {
18-
return '0x' + bytesToHex(b);
19-
}
11+
export type { HexString, BytesLike };
12+
export { toBytes, toHex, concat, compare };

src/core.test.ts

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fc from 'fast-check';
22
import assert from 'assert/strict';
3-
import { equalsBytes } from 'ethereum-cryptography/utils';
3+
import { HashZero as zero } from '@ethersproject/constants';
4+
import { keccak256 } from '@ethersproject/keccak256';
45
import {
56
makeMerkleTree,
67
getProof,
@@ -10,12 +11,9 @@ import {
1011
isValidMerkleTree,
1112
renderMerkleTree,
1213
} from './core';
13-
import { compareBytes, hex } from './bytes';
14-
import { keccak256 } from 'ethereum-cryptography/keccak';
14+
import { toHex, compare } from './bytes';
1515

16-
const zero = new Uint8Array(32);
17-
18-
const leaf = fc.uint8Array({ minLength: 32, maxLength: 32 }).map(x => PrettyBytes.from(x));
16+
const leaf = fc.uint8Array({ minLength: 32, maxLength: 32 }).map(toHex);
1917
const leaves = fc.array(leaf, { minLength: 1 });
2018
const leavesAndIndex = leaves.chain(xs => fc.tuple(fc.constant(xs), fc.nat({ max: xs.length - 1 })));
2119
const leavesAndIndices = leaves.chain(xs => fc.tuple(fc.constant(xs), fc.uniqueArray(fc.nat({ max: xs.length - 1 }))));
@@ -33,7 +31,7 @@ describe('core properties', () => {
3331
const proof = getProof(tree, treeIndex);
3432
const leaf = leaves[leafIndex]!;
3533
const impliedRoot = processProof(leaf, proof);
36-
return equalsBytes(root, impliedRoot);
34+
return root === impliedRoot;
3735
}),
3836
);
3937
});
@@ -49,46 +47,40 @@ describe('core properties', () => {
4947
if (leafIndices.length !== proof.leaves.length) return false;
5048
if (leafIndices.some(i => !proof.leaves.includes(leaves[i]!))) return false;
5149
const impliedRoot = processMultiProof(proof);
52-
return equalsBytes(root, impliedRoot);
50+
return root === impliedRoot;
5351
}),
5452
);
5553
});
5654
});
5755

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

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

6866
it('tree validity', () => {
6967
assert(!isValidMerkleTree([]), 'empty tree');
7068
assert(!isValidMerkleTree([zero, zero]), 'even number of nodes');
7169
assert(!isValidMerkleTree([zero, zero, zero]), 'inner node not hash of children');
7270

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

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

8078
const badMultiProof = {
81-
leaves: [128, 129].map(n => keccak256(Uint8Array.of(n))).sort(compareBytes),
79+
leaves: [128, 129].map(n => keccak256(Uint8Array.of(n))).sort(compare),
8280
proof: [leaf, leaf],
8381
proofFlags: [true, true, false],
8482
};
8583

86-
assert.throws(() => processMultiProof(badMultiProof), /^Error: Broken invariant$/);
84+
assert.throws(() => processMultiProof(badMultiProof), /^InvariantError$/);
8785
});
8886
});
89-
90-
class PrettyBytes extends Uint8Array {
91-
[fc.toStringMethod]() {
92-
return hex(this);
93-
}
94-
}

src/core.ts

Lines changed: 39 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { keccak256 } from 'ethereum-cryptography/keccak';
2-
import { concatBytes, bytesToHex, equalsBytes } from 'ethereum-cryptography/utils';
3-
import { Bytes, compareBytes } from './bytes';
4-
import { throwError } from './utils/throw-error';
1+
import { keccak256 } from '@ethersproject/keccak256';
2+
import { BytesLike, HexString, toHex, toBytes, concat, compare } from './bytes';
3+
import { invariant, throwError, validateArgument } from './utils/errors';
54

6-
const hashPair = (a: Bytes, b: Bytes) => keccak256(concatBytes(...[a, b].sort(compareBytes)));
5+
const hashPair = (a: BytesLike, b: BytesLike): HexString => keccak256(concat([a, b].sort(compare)));
76

87
const leftChildIndex = (i: number) => 2 * i + 1;
98
const rightChildIndex = (i: number) => 2 * i + 2;
@@ -13,26 +12,21 @@ const siblingIndex = (i: number) => (i > 0 ? i - (-1) ** (i % 2) : throwError('R
1312
const isTreeNode = (tree: unknown[], i: number) => i >= 0 && i < tree.length;
1413
const isInternalNode = (tree: unknown[], i: number) => isTreeNode(tree, leftChildIndex(i));
1514
const isLeafNode = (tree: unknown[], i: number) => isTreeNode(tree, i) && !isInternalNode(tree, i);
16-
const isValidMerkleNode = (node: Bytes) => node instanceof Uint8Array && node.length === 32;
15+
const isValidMerkleNode = (node: BytesLike) => toBytes(node).length === 32;
1716

18-
const checkTreeNode = (tree: unknown[], i: number) => void (isTreeNode(tree, i) || throwError('Index is not in tree'));
19-
const checkInternalNode = (tree: unknown[], i: number) =>
20-
void (isInternalNode(tree, i) || throwError('Index is not an internal tree node'));
2117
const checkLeafNode = (tree: unknown[], i: number) => void (isLeafNode(tree, i) || throwError('Index is not a leaf'));
22-
const checkValidMerkleNode = (node: Bytes) =>
18+
const checkValidMerkleNode = (node: BytesLike) =>
2319
void (isValidMerkleNode(node) || throwError('Merkle tree nodes must be Uint8Array of length 32'));
2420

25-
export function makeMerkleTree(leaves: Bytes[]): Bytes[] {
21+
export function makeMerkleTree(leaves: BytesLike[]): HexString[] {
2622
leaves.forEach(checkValidMerkleNode);
2723

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

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

3428
for (const [i, leaf] of leaves.entries()) {
35-
tree[tree.length - 1 - i] = leaf;
29+
tree[tree.length - 1 - i] = toHex(leaf);
3630
}
3731
for (let i = tree.length - 1 - leaves.length; i >= 0; i--) {
3832
tree[i] = hashPair(tree[leftChildIndex(i)]!, tree[rightChildIndex(i)]!);
@@ -41,22 +35,22 @@ export function makeMerkleTree(leaves: Bytes[]): Bytes[] {
4135
return tree;
4236
}
4337

44-
export function getProof(tree: Bytes[], index: number): Bytes[] {
38+
export function getProof(tree: BytesLike[], index: number): HexString[] {
4539
checkLeafNode(tree, index);
4640

4741
const proof = [];
4842
while (index > 0) {
49-
proof.push(tree[siblingIndex(index)]!);
43+
proof.push(toHex(tree[siblingIndex(index)]!));
5044
index = parentIndex(index);
5145
}
5246
return proof;
5347
}
5448

55-
export function processProof(leaf: Bytes, proof: Bytes[]): Bytes {
49+
export function processProof(leaf: BytesLike, proof: BytesLike[]): HexString {
5650
checkValidMerkleNode(leaf);
5751
proof.forEach(checkValidMerkleNode);
5852

59-
return proof.reduce(hashPair, leaf);
53+
return toHex(proof.reduce(hashPair, leaf));
6054
}
6155

6256
export interface MultiProof<T, L = T> {
@@ -65,13 +59,14 @@ export interface MultiProof<T, L = T> {
6559
proofFlags: boolean[];
6660
}
6761

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

72-
if (indices.slice(1).some((i, p) => i === indices[p])) {
73-
throw new Error('Cannot prove duplicated index');
74-
}
66+
validateArgument(
67+
indices.slice(1).every((i, p) => i !== indices[p]),
68+
'Cannot prove duplicated index',
69+
);
7570

7671
const stack = indices.concat(); // copy
7772
const proof = [];
@@ -87,54 +82,51 @@ export function getMultiProof(tree: Bytes[], indices: number[]): MultiProof<Byte
8782
stack.shift(); // consume from the stack
8883
} else {
8984
proofFlags.push(false);
90-
proof.push(tree[s]!);
85+
proof.push(toHex(tree[s]!));
9186
}
9287
stack.push(p);
9388
}
9489

9590
if (indices.length === 0) {
96-
proof.push(tree[0]!);
91+
proof.push(toHex(tree[0]!));
9792
}
9893

9994
return {
100-
leaves: indices.map(i => tree[i]!),
95+
leaves: indices.map(i => toHex(tree[i]!)),
10196
proof,
10297
proofFlags,
10398
};
10499
}
105100

106-
export function processMultiProof(multiproof: MultiProof<Bytes>): Bytes {
101+
export function processMultiProof(multiproof: MultiProof<BytesLike>): HexString {
107102
multiproof.leaves.forEach(checkValidMerkleNode);
108103
multiproof.proof.forEach(checkValidMerkleNode);
109104

110-
if (multiproof.proof.length < multiproof.proofFlags.filter(b => !b).length) {
111-
throw new Error('Invalid multiproof format');
112-
}
113-
114-
if (multiproof.leaves.length + multiproof.proof.length !== multiproof.proofFlags.length + 1) {
115-
throw new Error('Provided leaves and multiproof are not compatible');
116-
}
105+
validateArgument(
106+
multiproof.proof.length >= multiproof.proofFlags.filter(b => !b).length,
107+
'Invalid multiproof format',
108+
);
109+
validateArgument(
110+
multiproof.leaves.length + multiproof.proof.length === multiproof.proofFlags.length + 1,
111+
'Provided leaves and multiproof are not compatible',
112+
);
117113

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

121117
for (const flag of multiproof.proofFlags) {
122118
const a = stack.shift();
123119
const b = flag ? stack.shift() : proof.shift();
124-
if (a === undefined || b === undefined) {
125-
throw new Error('Broken invariant');
126-
}
120+
invariant(a !== undefined && b !== undefined);
127121
stack.push(hashPair(a, b));
128122
}
129123

130-
if (stack.length + proof.length !== 1) {
131-
throw new Error('Broken invariant');
132-
}
124+
invariant(stack.length + proof.length === 1);
133125

134-
return stack.pop() ?? proof.shift()!;
126+
return toHex(stack.pop() ?? proof.shift()!);
135127
}
136128

137-
export function isValidMerkleTree(tree: Bytes[]): boolean {
129+
export function isValidMerkleTree(tree: BytesLike[]): boolean {
138130
for (const [i, node] of tree.entries()) {
139131
if (!isValidMerkleNode(node)) {
140132
return false;
@@ -147,18 +139,16 @@ export function isValidMerkleTree(tree: Bytes[]): boolean {
147139
if (l < tree.length) {
148140
return false;
149141
}
150-
} else if (!equalsBytes(node, hashPair(tree[l]!, tree[r]!))) {
142+
} else if (compare(node, hashPair(tree[l]!, tree[r]!))) {
151143
return false;
152144
}
153145
}
154146

155147
return tree.length > 0;
156148
}
157149

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

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

@@ -178,7 +168,7 @@ export function renderMerkleTree(tree: Bytes[]): string {
178168
.join('') +
179169
i +
180170
') ' +
181-
bytesToHex(tree[i]!),
171+
toHex(tree[i]!),
182172
);
183173

184174
if (rightChildIndex(i) < tree.length) {

src/index.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import assert from 'assert/strict';
2+
import { SimpleMerkleTree, StandardMerkleTree } from '.';
3+
4+
describe('index properties', () => {
5+
it('classes are exported', () => {
6+
assert.notEqual(SimpleMerkleTree, undefined);
7+
assert.notEqual(StandardMerkleTree, undefined);
8+
});
9+
});

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export { SimpleMerkleTree } from './simple';
12
export { StandardMerkleTree } from './standard';

0 commit comments

Comments
 (0)