Skip to content
Open
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
3 changes: 3 additions & 0 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DashboardLayout } from '@/components/layouts/DashboardLayout';
import { TransactionHistorySection } from '@/components/TransactionHistorySection';
import { BulkClaimSection } from '@/components/BulkClaimSection';

export default function DashboardPage() {
return (
Expand All @@ -20,6 +21,8 @@ export default function DashboardPage() {
<p className="mt-2 text-sm text-gray-600">Connect a wallet to see your holdings.</p>
</section>

<BulkClaimSection />

<TransactionHistorySection />
</div>
</DashboardLayout>
Expand Down
184 changes: 184 additions & 0 deletions components/BulkClaimSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import { buildMerkleTreeAsync, supportsWorkers } from '@/lib/merkleClient';
import type { BytesLike, Hex } from '@/lib/merkle';

const LEAF_COUNT = 10_000;

interface Result {
root: Hex;
ms: number;
}

/**
* Build a deterministic 8-byte Uint8Array per leaf so the demo is
* reproducible across renders and across component mounts. Two 32-bit
* words: the leaf index plus a splitmix-style salt seeded from it.
* Hoisted to module scope so HMR + route remounts don't re-allocate
* 10,000 buffers each visit.
*/
const CLAIM_LEAVES: BytesLike[] = (() => {
const out: BytesLike[] = new Array(LEAF_COUNT);
for (let i = 0; i < LEAF_COUNT; i++) {
const buf = new Uint8Array(8);
const view = new DataView(buf.buffer);
let x = (i + 1) >>> 0;
x = (x ^ (x >>> 16)) * 0x85ebca6b >>> 0;
x = (x ^ (x >>> 13)) * 0xc2b2ae35 >>> 0;
view.setUint32(0, i);
view.setUint32(4, x >>> 0);
out[i] = buf;
}
return out;
})();

export function BulkClaimSection() {
const leaves = CLAIM_LEAVES;
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState(0);
const [result, setResult] = useState<Result | null>(null);
const [error, setError] = useState<string | null>(null);
const [workerAvailable, setWorkerAvailable] = useState<boolean | null>(null);
const abortRef = useRef<AbortController | null>(null);

// supportsWorkers() depends on globals that only exist in the browser;
// defer detection until mount to avoid SSR-mismatch hydration warnings.
useEffect(() => {
setWorkerAvailable(supportsWorkers());
}, []);

async function handleGenerate() {
setRunning(true);
setProgress(0);
setError(null);
setResult(null);

const ctrl = new AbortController();
abortRef.current = ctrl;

const t0 = typeof performance !== 'undefined' ? performance.now() : Date.now();
try {
const tree = await buildMerkleTreeAsync(leaves, {
onProgress: (p, t) =>
setProgress(t === 0 ? 0 : Math.min(100, Math.round((p / t) * 100))),
signal: ctrl.signal
});
const t1 = typeof performance !== 'undefined' ? performance.now() : Date.now();
setResult({ root: tree.root, ms: t1 - t0 });
setProgress(100);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
setError('Cancelled');
} else {
setError(err instanceof Error ? err.message : String(err));
}
setProgress(0);
} finally {
setRunning(false);
abortRef.current = null;
}
}

function handleCancel() {
abortRef.current?.abort();
}

return (
<section id="bulk-claim" className="card lg:col-span-2">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-gray-900">Bulk claim preview</h2>
<p className="mt-1 text-xs text-gray-500">
Generate a Merkle root for {LEAF_COUNT.toLocaleString()} claim leaves.
{workerAvailable === null
? ''
: workerAvailable
? ' Hashing runs in a Web Worker; this UI stays responsive.'
: ' Workers unsupported — hashing falls back to the main thread and yields between batches.'}
</p>
</div>
<span
className={
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ' +
(workerAvailable
? 'bg-blue-100 text-blue-700'
: workerAvailable === false
? 'bg-amber-100 text-amber-700'
: 'bg-gray-100 text-gray-500')
}
>
{workerAvailable === null ? 'Detecting…' : workerAvailable ? 'Worker' : 'Main thread'}
</span>
</div>

<div className="mt-4 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleGenerate}
disabled={running}
className="btn"
aria-busy={running}
>
{running
? 'Generating…'
: `Generate Merkle root (${LEAF_COUNT.toLocaleString()} leaves)`}
</button>
{running && (
<button type="button" onClick={handleCancel} className="btn-outline">
Cancel
</button>
)}
</div>

{/* Progress (visual only — the bar has its own role="progressbar"
so SRs get accessibility from that, without aria-live noise). */}
{(running || progress > 0) && (
<div className="mt-4">
<div
className="h-2 w-full overflow-hidden rounded bg-gray-200"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
aria-label="Merkle build progress"
>
<div
className="h-full bg-blue-600 transition-all duration-150 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
<p aria-hidden="true" className="mt-1 text-xs text-gray-500">{progress}%</p>
</div>
)}

{/* Outcome region: a short sr-only summary is announced when the
build finishes; the 64-char hex root is shown but labelled
aria-hidden so screen readers don't read the whole thing. */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{result && `Merkle root computed in ${result.ms.toFixed(0)} milliseconds for ${leaves.length.toLocaleString()} leaves`}
</div>

{result && (
<div className="mt-4 rounded border border-gray-200 bg-gray-50 p-3 text-sm" aria-hidden="true">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-700">Merkle root</span>
<span className="text-xs text-gray-500">
{result.ms.toFixed(0)} ms · {leaves.length.toLocaleString()} leaves
</span>
</div>
<div
className="mt-1 break-all font-mono text-xs text-gray-900"
title={result.root}
>
{result.root}
</div>
</div>
)}

{error && (
<p role="alert" className="mt-4 text-sm text-red-600">{error}</p>
)}
</section>
);
}
170 changes: 170 additions & 0 deletions lib/merkle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { describe, expect, it } from 'vitest';
import { concat, keccak256 } from 'viem';
import { buildMerkleTree, getProof, hashData, verifyProof, type Hex } from './merkle';

const ZERO32: Hex = `0x${'00'.repeat(32)}`;

describe('hashData', () => {
it('hashes a UTF-8 string to a 32-byte hex', async () => {
const h = hashData('hello');
expect(h).toMatch(/^0x[0-9a-f]{64}$/);
});

it('hashes a hex string consistently', async () => {
expect(hashData('0x01')).toBe(hashData('0x01'));
});

it('hashes a Uint8Array to a 32-byte hex', async () => {
const h = hashData(new Uint8Array([1, 2, 3]));
expect(h).toMatch(/^0x[0-9a-f]{64}$/);
});

it('produces different hashes for different inputs', async () => {
expect(hashData('a')).not.toBe(hashData('b'));
});

it('matches the known keccak256 of an empty string', async () => {
// Canonical reference vector from multiple Ethereum sources.
expect(hashData('')).toBe(
'0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470'
);
});
});

describe('buildMerkleTree', () => {
it('returns the zero root for an empty input', async () => {
const tree = await buildMerkleTree([]);
expect(tree.root).toBe(ZERO32);
expect(tree.layers).toEqual([[]]);
});

it('returns the single leaf as both root and leaf layer', async () => {
const tree = await buildMerkleTree(['only']);
expect(tree.root).toBe(hashData('only'));
expect(tree.layers).toEqual([[hashData('only')]]);
});

it('builds a deterministic tree for identical inputs', async () => {
const a = await buildMerkleTree(['a', 'b', 'c', 'd']);
const b = await buildMerkleTree(['a', 'b', 'c', 'd']);
expect(a.root).toBe(b.root);
expect(a.layers).toEqual(b.layers);
});

it('duplicates the last leaf for an odd layer count', async () => {
const odd = await buildMerkleTree(['a', 'b', 'c']);
// Layer 0: hashData(a), hashData(b), hashData(c)
// Layer 1: keccak(h(a) || h(b)), keccak(h(c) || h(c)) [self-paired]
// Layer 2 (root): keccak(L1[0] || L1[1])
const Ha = hashData('a');
const Hb = hashData('b');
const Hc = hashData('c');
expect(odd.root).toMatch(/^0x[0-9a-f]{64}$/);
expect(odd.layers[1].length).toBe(2);
// For index 2 in a 3-leaf tree, the duplicate-last convention means
// the leaf pairs with itself, so the first sibling is the leaf hash,
// and the second sibling is the parent of (a, b).
expect(
verifyProof(
{ leaf: Hc, siblings: [Hc, odd.layers[1][0]], index: 2 },
odd.root
)
).toBe(true);
// Shape-level contract: layer 1 must be [keccak(Ha||Hb), keccak(Hc||Hc)].
expect(odd.layers[0]).toEqual([Ha, Hb, Hc]);
expect(odd.layers[1][0]).toBe(keccak256(concat([Ha, Hb]), 'hex'));
expect(odd.layers[1][1]).toBe(keccak256(concat([Hc, Hc]), 'hex'));
});

it('round-trips proofs for an odd-count tree of 5 leaves', async () => {
// 5 leaves → layer 0 size 5 (odd) → layer 1 size 3 (odd) → layer 2 size 2 → root.
// This exercises the duplicate-self convention across TWO odd boundary
// layers, which prior tests at size 3 did not.
const items = ['a', 'b', 'c', 'd', 'e'];
const tree = await buildMerkleTree(items);
// Layer sizes encode the duplicate-last-odd contract: 5, 3, 2, 1.
expect(tree.layers.map((l) => l.length)).toEqual([5, 3, 2, 1]);
for (let i = 0; i < items.length; i++) {
const proof = getProof(tree, i);
expect(
verifyProof({ leaf: hashData(items[i]), siblings: proof.siblings, index: i }, tree.root)
).toBe(true);
}
});

it('round-trips proofs for an odd-count tree of 7 leaves (three odd crossings)', async () => {
const items = Array.from({ length: 7 }, (_, i) => `item-${i}`);
const tree = await buildMerkleTree(items);
// Layer sizes: 7 → 4 → 2 → 1 (note: the 4 IS even because the odd 7
// produces a self-paired last element, which becomes one half of
// an even pair).
expect(tree.layers.map((l) => l.length)).toEqual([7, 4, 2, 1]);
for (let i = 0; i < items.length; i++) {
const proof = getProof(tree, i);
expect(
verifyProof({ leaf: hashData(items[i]), siblings: proof.siblings, index: i }, tree.root)
).toBe(true);
}
});

it('emits progress monotonically and exactly at completion', async () => {
const items = Array.from({ length: 20 }, (_, i) => i.toString());
const events: Array<[number, number]> = [];
await buildMerkleTree(items, {
onProgress: (p, t) => events.push([p, t]),
batchSize: 4
});
expect(events.length).toBeGreaterThan(0);
expect(events[events.length - 1]).toEqual([
events[events.length - 1][1],
events[events.length - 1][1]
]);
for (let i = 1; i < events.length; i++) {
expect(events[i][0]).toBeGreaterThanOrEqual(events[i - 1][0]);
}
});

it('two leaves yield the keccak256 root of left||right (known vector)', async () => {
const Ha = hashData('a');
const Hb = hashData('b');
// Independently compute what the root of two pre-hashed leaves MUST
// be: keccak(ha || hb). If our buildMerkleTree agrees, its pair-
// hashing is consistent with viem's primitives.
const expected = keccak256(concat([Ha, Hb]), 'hex');
const tree = await buildMerkleTree(['a', 'b']);
expect(tree.root).toBe(expected);
});
});

describe('getProof + verifyProof', () => {
it('round-trips every leaf back to the root', async () => {
const items = ['lorem', 'ipsum', 'dolor', 'sit'];
const tree = await buildMerkleTree(items);
for (let i = 0; i < items.length; i++) {
const proof = getProof(tree, i);
expect(
verifyProof({ leaf: hashData(items[i]), siblings: proof.siblings, index: i }, tree.root)
).toBe(true);
}
});

it('throws for an out-of-range index', async () => {
const tree = await buildMerkleTree(['a']);
expect(() => getProof(tree, -1)).toThrow(/out of range/);
expect(() => getProof(tree, 1)).toThrow(/out of range/);
});

it('rejects a tampered leaf', async () => {
const tree = await buildMerkleTree(['a', 'b', 'c', 'd']);
const proof = getProof(tree, 0);
expect(
verifyProof({ leaf: hashData('tampered'), siblings: proof.siblings, index: 0 }, tree.root)
).toBe(false);
});

it('rejects a proof targeting the wrong root', async () => {
const tree = await buildMerkleTree(['a', 'b', 'c', 'd']);
const proof = getProof(tree, 0);
expect(verifyProof(proof, ZERO32)).toBe(false);
});
});
Loading