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

Introduce SimpleMerkleTree & migrate to ethers.js #31

Closed
wants to merge 27 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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,731 changes: 172 additions & 1,559 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 @@ -22,7 +22,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
28 changes: 13 additions & 15 deletions src/bytes.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
import { bytesToHex } from 'ethereum-cryptography/utils';
import type { Bytes, BytesLike } from '@ethersproject/bytes';
type HexString = string;

export type Bytes = Uint8Array;
import {
isBytesLike,
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));
return diff > 0 ? 1 : diff < 0 ? -1 : 0;
}

export function hex(b: Bytes): string {
return '0x' + bytesToHex(b);
}
export type { HexString, Bytes, BytesLike };
export { isBytesLike, toBytes, toHex, concat, compare };
23 changes: 7 additions & 16 deletions src/core.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
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, processProof, getMultiProof, processMultiProof, 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 @@ -25,7 +23,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 @@ -41,7 +39,7 @@ 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;
}),
);
});
Expand Down Expand Up @@ -79,7 +77,7 @@ describe('core error conditions', () => {
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],
};
Expand All @@ -89,11 +87,4 @@ describe('core error conditions', () => {
/^Error: Broken invariant$/,
);
});

});

class PrettyBytes extends Uint8Array {
[fc.toStringMethod]() {
return hex(this);
}
}
43 changes: 21 additions & 22 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 { keccak256 } from '@ethersproject/keccak256';
import { BytesLike, HexString, toHex, toBytes, concat, compare } from './bytes';
import { throwError } from './utils/throw-error';

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,24 +12,24 @@ const siblingIndex = (i: number) => i > 0 ? i - (-1) ** (i % 2) : throwEr
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) => void (isValidMerkleNode(node) || throwError('Merkle tree nodes must be Uint8Array of length 32'));
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');
}

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(
Expand All @@ -42,22 +41,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));
}

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 @@ -66,7 +65,7 @@ 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);

Expand Down Expand Up @@ -98,13 +97,13 @@ 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)),
proofFlags,
};
}

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

Expand Down Expand Up @@ -132,10 +131,10 @@ export function processMultiProof(multiproof: MultiProof<Bytes>): Bytes {
throw new Error('Broken invariant');
}

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 @@ -148,15 +147,15 @@ 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;
}
}

return tree.length > 0;
}

export function renderMerkleTree(tree: Bytes[]): string {
export function renderMerkleTree(tree: BytesLike[]): HexString {
if (tree.length === 0) {
throw new Error('Expected non-zero number of nodes');
}
Expand All @@ -172,7 +171,7 @@ export function renderMerkleTree(tree: Bytes[]): string {
path.slice(0, -1).map(p => [' ', '│ '][p]).join('') +
path.slice(-1).map(p => ['└─ ', '├─ '][p]).join('') +
i + ') ' +
bytesToHex(tree[i]!)
toHex(tree[i]!)
);

if (rightChildIndex(i) < tree.length) {
Expand Down
11 changes: 11 additions & 0 deletions src/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { HexString } from "./bytes";

// Dump/Load format
export type MerkleTreeData<T> = {
format: 'standard-v1' | 'simple-v1';
tree: HexString[];
values: {
value: T;
treeIndex: number;
}[];
}

Check warning on line 11 in src/format.ts

View check run for this annotation

Codecov / codecov/patch

src/format.ts#L1-L11

Added lines #L1 - L11 were not covered by tests
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);
});
});
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
export type { MerkleTreeData } from './format';
export type { MerkleTreeOptions } from './options';

export { SimpleMerkleTree } from './simple';
export { StandardMerkleTree } from './standard';
Loading
Loading