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

Support custom hashing functions #34

Closed
wants to merge 1 commit 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down Expand Up @@ -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`

Expand Down
5 changes: 4 additions & 1 deletion src/bytes.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down
187 changes: 110 additions & 77 deletions src/core.test.ts
Original file line number Diff line number Diff line change
@@ -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]() {
Expand Down
17 changes: 6 additions & 11 deletions src/core.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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) {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -104,7 +99,7 @@ export function getMultiProof(tree: Bytes[], indices: number[]): MultiProof<Byte
};
}

export function processMultiProof(multiproof: MultiProof<Bytes>): Bytes {
export function processMultiProof(multiproof: MultiProof<Bytes>, hashPair: HashPair): Bytes {
multiproof.leaves.forEach(checkValidMerkleNode);
multiproof.proof.forEach(checkValidMerkleNode);

Expand Down Expand Up @@ -135,7 +130,7 @@ export function processMultiProof(multiproof: MultiProof<Bytes>): 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;
Expand Down
7 changes: 7 additions & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
@@ -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<MerkleTreeOptions> = {
sortLeaves: true,
hashFn: standardHash,
};
Loading
Loading