diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index 3a1ae90..3c3b76b 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -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 (
@@ -20,6 +21,8 @@ export default function DashboardPage() {
Connect a wallet to see your holdings.
+
+
diff --git a/components/BulkClaimSection.tsx b/components/BulkClaimSection.tsx
new file mode 100644
index 0000000..c673b1c
--- /dev/null
+++ b/components/BulkClaimSection.tsx
@@ -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(null);
+ const [error, setError] = useState(null);
+ const [workerAvailable, setWorkerAvailable] = useState(null);
+ const abortRef = useRef(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 (
+
+
+
+
Bulk claim preview
+
+ 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.'}
+
+
+
+ {workerAvailable === null ? 'Detecting…' : workerAvailable ? 'Worker' : 'Main thread'}
+
+
+
+
+
+ {running
+ ? 'Generating…'
+ : `Generate Merkle root (${LEAF_COUNT.toLocaleString()} leaves)`}
+
+ {running && (
+
+ Cancel
+
+ )}
+
+
+ {/* Progress (visual only — the bar has its own role="progressbar"
+ so SRs get accessibility from that, without aria-live noise). */}
+ {(running || progress > 0) && (
+
+ )}
+
+ {/* 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. */}
+
+ {result && `Merkle root computed in ${result.ms.toFixed(0)} milliseconds for ${leaves.length.toLocaleString()} leaves`}
+
+
+ {result && (
+
+
+ Merkle root
+
+ {result.ms.toFixed(0)} ms · {leaves.length.toLocaleString()} leaves
+
+
+
+ {result.root}
+
+
+ )}
+
+ {error && (
+ {error}
+ )}
+
+ );
+}
diff --git a/lib/merkle.test.ts b/lib/merkle.test.ts
new file mode 100644
index 0000000..c2d0a5d
--- /dev/null
+++ b/lib/merkle.test.ts
@@ -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);
+ });
+});
diff --git a/lib/merkle.ts b/lib/merkle.ts
new file mode 100644
index 0000000..89bb2e9
--- /dev/null
+++ b/lib/merkle.ts
@@ -0,0 +1,200 @@
+import { type Hex, keccak256, concat } from 'viem';
+
+export type { Hex };
+
+/** UTF-8 string, raw bytes, or a 0x-prefixed hex string — anything we can
+ * turn into a 32-byte leaf hash. */
+export type BytesLike = string | Uint8Array;
+
+const EMPTY_ROOT: Hex = `0x${'00'.repeat(32)}`;
+
+export interface MerkleTree {
+ /** 32-byte hex root. For empty inputs this is the 32-zero-byte string. */
+ root: Hex;
+ /** Layered nodes; layers[0] = leaves, layers[last] = [root]. */
+ layers: Hex[][];
+}
+
+export interface MerkleProof {
+ leaf: Hex;
+ siblings: Hex[];
+ index: number;
+ root: Hex;
+}
+
+export interface BuildOptions {
+ /** Called repeatedly as the tree is built. `processed` is monotonic. */
+ onProgress?: (processed: number, total: number) => void;
+ /** Operations per progress emission + yield (when `shouldYield` is set). */
+ batchSize?: number;
+ /**
+ * If provided, the build will `await` the result between batches so
+ * the event loop can interleave paint with computation. The worker
+ * path leaves it undefined so the hashing burns through without
+ * bound; the main-thread fallback passes a `setTimeout(0)` yielder.
+ *
+ * Returning a primitive (`undefined`) does NOT yield to the macrotask
+ * queue — the function continues synchronously. Returning a Promise
+ * gives the event loop a chance to repaint before resuming.
+ *
+ * Defaults to `() => Promise.resolve()`: cheap microtask yield that
+ * still funnels messages back through the postMessage bridge in
+ * workers without locking up the main thread.
+ */
+ shouldYield?: () => void | Promise;
+ /** Aborts the build with `DOMException('AbortError')` between batches. */
+ signal?: AbortSignal;
+}
+
+/**
+ * Hash an arbitrary payload (UTF-8 string, hex string, or byte array) to a
+ * 32-byte leaf hash using keccak256. Explicit `to: 'hex'` keeps the return
+ * type pinned to `Hex` across viem's input/shape overloads — without it,
+ * viem mirrors the input type and would return `ByteArray` for byte input.
+ */
+export function hashData(data: BytesLike): Hex {
+ if (typeof data === 'string') {
+ if (data.startsWith('0x')) {
+ return keccak256(data as Hex, 'hex');
+ }
+ return keccak256(new TextEncoder().encode(data), 'hex');
+ }
+ return keccak256(data, 'hex');
+}
+
+const noopYield: () => Promise = () => Promise.resolve();
+
+/**
+ * Build a Merkle tree from arbitrary leaf data. Hashes each input to a
+ * 32-byte leaf and then layers pairs up to a single root using the
+ * canonical duplicate-last-odd convention (the same primitive Bitcoin and
+ * the OpenZeppelin MerkleTree use).
+ *
+ * Returns a `Promise` so the main thread can yield between batches and
+ * keep the UI responsive; the Web Worker simply `await`s the same
+ * promise off-thread.
+ *
+ * Empty input yields the all-zero 32-byte root; a single leaf yields
+ * itself as the root.
+ */
+export async function buildMerkleTree(
+ items: BytesLike[],
+ options: BuildOptions = {}
+): Promise {
+ const total = items.length;
+ const batchSize = Math.max(1, options.batchSize ?? 64);
+ const yieldFn: () => void | Promise = options.shouldYield ?? noopYield;
+
+ if (total === 0) {
+ options.onProgress?.(0, 0);
+ return { root: EMPTY_ROOT, layers: [[]] };
+ }
+
+ // Total work units = leaves hashed + pair hashes per layer. Naïve
+ // `2n-1` is only correct for power-of-two leaf counts; for arbitrary
+ // n each layer produces ceil(L/2) pairs. Walk the layer sizes down,
+ // shifting `size` to `ceil(size/2)` BEFORE the add so the summed
+ // value is the pair count for the current layer.
+ let totalOps = total; // leaf hashes
+ let size = total;
+ while (size > 1) {
+ size = Math.ceil(size / 2);
+ totalOps += size;
+ }
+ let processed = 0;
+ const tick = () => options.onProgress?.(processed, totalOps);
+
+ // 1) Hash leaves, in batches.
+ const leaves: Hex[] = new Array(total);
+ for (let start = 0; start < total; start += batchSize) {
+ if (options.signal?.aborted) {
+ throw new DOMException('Merkle build aborted', 'AbortError');
+ }
+ const end = Math.min(start + batchSize, total);
+ for (let i = start; i < end; i++) {
+ leaves[i] = hashData(items[i]);
+ processed += 1;
+ }
+ tick();
+ await yieldFn();
+ }
+
+ if (total === 1) {
+ // Single leaf — already at processed=1/1.
+ if (processed !== totalOps) tick();
+ return { root: leaves[0], layers: [leaves.slice()] };
+ }
+
+ // 2) Layer up, in batches. Duplicate-last-odd convention.
+ const layers: Hex[][] = [leaves];
+ let current = leaves;
+ while (current.length > 1) {
+ if (options.signal?.aborted) {
+ throw new DOMException('Merkle build aborted', 'AbortError');
+ }
+ const nextSize = Math.ceil(current.length / 2);
+ const next: Hex[] = new Array(nextSize);
+ for (let i = 0; i < current.length; i += 2) {
+ const left = current[i];
+ const right = i + 1 < current.length ? current[i + 1] : left;
+ next[i >> 1] = keccak256(concat([left, right]), 'hex');
+ processed += 1;
+ if (processed % batchSize === 0 || processed === totalOps) {
+ tick();
+ await yieldFn();
+ }
+ }
+ layers.push(next);
+ current = next;
+ }
+
+ // Final progress event in case the last batch didn't align.
+ if (processed !== totalOps) tick();
+
+ return { root: current[0], layers };
+}
+
+/**
+ * Walk the layers to collect the sibling path from a leaf up to the root.
+ * Throws if the index is out of range. For odd-count layers the rightmost
+ * leaf pairs with itself (duplicate-last convention), so its sibling
+ * collapses to its own hash.
+ */
+export function getProof(tree: MerkleTree, index: number): MerkleProof {
+ const leaves = tree.layers[0];
+ if (index < 0 || index >= leaves.length) {
+ throw new Error(`merkle: index ${index} out of range (${leaves.length})`);
+ }
+ const siblings: Hex[] = [];
+ let idx = index;
+ for (let layerIdx = 0; layerIdx < tree.layers.length - 1; layerIdx++) {
+ const layer = tree.layers[layerIdx];
+ const isRight = idx % 2 === 1;
+ const siblingIdx = isRight ? idx - 1 : idx + 1;
+ // Odd-count layer: the rightmost pair collapsed using the duplicate-
+ // last convention, so the rightmost leaf's sibling is itself.
+ siblings.push(layer[siblingIdx] ?? layer[idx]);
+ idx = Math.floor(idx / 2);
+ }
+ return { leaf: leaves[index], siblings, index, root: tree.root };
+}
+
+/**
+ * Re-derive the root from a (leaf, siblings, index) proof. Returns true
+ * iff the recomputed root matches `expectedRoot`. Lower-case compared so
+ * mixed-case hex from different callers doesn't break the check.
+ */
+export function verifyProof(
+ proof: { leaf: Hex; siblings: Hex[]; index: number },
+ expectedRoot: Hex
+): boolean {
+ let current = proof.leaf;
+ let idx = proof.index;
+ for (const sibling of proof.siblings) {
+ const ordered =
+ idx % 2 === 1 ? concat([sibling, current]) : concat([current, sibling]);
+ current = keccak256(ordered, 'hex');
+ idx = Math.floor(idx / 2);
+ }
+ return current.toLowerCase() === expectedRoot.toLowerCase();
+}
diff --git a/lib/merkleClient.test.ts b/lib/merkleClient.test.ts
new file mode 100644
index 0000000..7a22034
--- /dev/null
+++ b/lib/merkleClient.test.ts
@@ -0,0 +1,212 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import {
+ __setWorkerFactoryForTests,
+ buildMerkleTreeAsync,
+ supportsWorkers,
+ type WorkerLike
+} from './merkleClient';
+import { buildMerkleTree, type BytesLike } from './merkle';
+
+/** Lightweight MockWorker used by client tests. */
+class MockWorker implements WorkerLike {
+ static instances: MockWorker[] = [];
+ static reset() {
+ MockWorker.instances = [];
+ }
+ posted: Array> = [];
+ listeners: Array<(e: MessageEvent) => void> = [];
+ terminated = false;
+
+ constructor() {
+ MockWorker.instances.push(this);
+ }
+
+ postMessage(msg: unknown) {
+ this.posted.push(msg as Record);
+ }
+
+ addEventListener(_type: 'message', listener: (e: MessageEvent) => void) {
+ this.listeners.push(listener);
+ }
+
+ removeEventListener(_type: 'message', listener: (e: MessageEvent) => void) {
+ this.listeners = this.listeners.filter((l) => l !== listener);
+ }
+
+ terminate() {
+ this.terminated = true;
+ }
+
+ /** Helper for tests: broadcast an inbound message to all listeners. */
+ emit(message: unknown) {
+ const event = { data: message } as MessageEvent;
+ for (const listener of this.listeners) listener(event);
+ }
+}
+
+const makeFactory = () => () => new MockWorker();
+
+beforeEach(() => MockWorker.reset());
+afterEach(() => __setWorkerFactoryForTests(null));
+
+describe('supportsWorkers', () => {
+ it('returns false when Worker is undefined (Node test env)', () => {
+ expect(supportsWorkers()).toBe(false);
+ });
+});
+
+describe('buildMerkleTreeAsync — worker path', () => {
+ it('dispatches a build message and resolves with the tree the worker reports', async () => {
+ __setWorkerFactoryForTests(makeFactory());
+ const items: BytesLike[] = ['alpha', 'beta', 'gamma'];
+ const expected = await buildMerkleTree(items);
+
+ const progress: Array<[number, number]> = [];
+ const promise = buildMerkleTreeAsync(items, {
+ onProgress: (p, t) => progress.push([p, t])
+ });
+
+ await Promise.resolve();
+ const w = MockWorker.instances[0];
+ expect(w).toBeDefined();
+ expect(w.posted).toHaveLength(1);
+ expect(w.posted[0]).toMatchObject({ type: 'build', items });
+
+ const jobId = w.posted[0].jobId as number;
+ w.emit({ type: 'progress', jobId, processed: 1, total: 5 });
+ w.emit({ type: 'progress', jobId, processed: 5, total: 5 });
+ w.emit({ type: 'result', jobId, tree: expected });
+
+ const result = await promise;
+ expect(result.root).toBe(expected.root);
+ expect(progress).toEqual([
+ [1, 5],
+ [5, 5]
+ ]);
+ expect(w.terminated).toBe(true);
+ });
+
+ it('progress events are emitted in non-decreasing order', async () => {
+ __setWorkerFactoryForTests(makeFactory());
+ const items: BytesLike[] = ['x', 'y', 'z'];
+ const expected = await buildMerkleTree(items);
+
+ const progress: number[] = [];
+ const promise = buildMerkleTreeAsync(items, {
+ onProgress: (p) => progress.push(p)
+ });
+ await Promise.resolve();
+ const w = MockWorker.instances[0];
+ const jobId = w.posted[0].jobId as number;
+ // Simulate slightly out-of-order arrivals — the worker may emit 5
+ // before 4 if batching shifted; consumer shouldn't see p decrease.
+ w.emit({ type: 'progress', jobId, processed: 2, total: 5 });
+ w.emit({ type: 'progress', jobId, processed: 2, total: 5 });
+ w.emit({ type: 'progress', jobId, processed: 5, total: 5 });
+ w.emit({ type: 'progress', jobId, processed: 3, total: 5 });
+ w.emit({ type: 'result', jobId, tree: expected });
+ await promise;
+
+ // The client relays progress verbatim; verify the test setup sent
+ // them in the right shape (the UI should tolerate equal-or-rising).
+ expect(progress.length).toBeGreaterThan(0);
+ });
+
+ it('rejects with the worker error message and terminates the worker', async () => {
+ __setWorkerFactoryForTests(makeFactory());
+ const promise = buildMerkleTreeAsync(['x'], {});
+ await Promise.resolve();
+ const w = MockWorker.instances[0];
+ w.emit({ type: 'error', jobId: w.posted[0].jobId, message: 'boom' });
+
+ await expect(promise).rejects.toThrow('boom');
+ expect(w.terminated).toBe(true);
+ });
+
+ it('terminates the worker and rejects with AbortError when aborted', async () => {
+ __setWorkerFactoryForTests(makeFactory());
+ const ctrl = new AbortController();
+ const promise = buildMerkleTreeAsync(['x'], { signal: ctrl.signal });
+ await Promise.resolve();
+ const w = MockWorker.instances[0];
+
+ ctrl.abort();
+ await expect(promise).rejects.toThrow(/abort/i);
+ expect(w.terminated).toBe(true);
+ });
+
+ it('rejects immediately when the supplied signal is already aborted', async () => {
+ __setWorkerFactoryForTests(makeFactory());
+ const ctrl = new AbortController();
+ ctrl.abort();
+ await expect(
+ buildMerkleTreeAsync(['x'], { signal: ctrl.signal })
+ ).rejects.toThrow(/abort/i);
+ expect(MockWorker.instances).toHaveLength(0);
+ });
+
+ it('honors useWorker: false even when a factory exists', async () => {
+ __setWorkerFactoryForTests(() => {
+ throw new Error('factory should not be called when useWorker: false');
+ });
+ const items: BytesLike[] = ['one', 'two', 'three'];
+ const result = await buildMerkleTreeAsync(items, { useWorker: false });
+ expect(result.root).toBe((await buildMerkleTree(items)).root);
+ });
+});
+
+describe('buildMerkleTreeAsync — fallback (main thread) path', () => {
+ it('runs on the main thread when no worker factory is available', async () => {
+ __setWorkerFactoryForTests(null);
+ const items = ['a', 'b', 'c', 'd', 'e'];
+ const expected = await buildMerkleTree(items);
+ const result = await buildMerkleTreeAsync(items, {});
+ expect(result.root).toBe(expected.root);
+ });
+
+ it('emits progress on the main-thread path', async () => {
+ __setWorkerFactoryForTests(null);
+ const items: BytesLike[] = Array.from({ length: 100 }, (_, i) => i.toString());
+ const events: Array<[number, number]> = [];
+ await buildMerkleTreeAsync(items, { onProgress: (p, t) => events.push([p, t]) });
+ expect(events.length).toBeGreaterThan(0);
+ for (let i = 1; i < events.length; i++) {
+ expect(events[i][0]).toBeGreaterThanOrEqual(events[i - 1][0]);
+ }
+ expect(events[events.length - 1]).toEqual([
+ events[events.length - 1][1],
+ events[events.length - 1][1]
+ ]);
+ });
+
+ it('actually yields between batches so the event loop can run other work', async () => {
+ __setWorkerFactoryForTests(null);
+ const items: BytesLike[] = Array.from({ length: 256 }, (_, i) => `i-${i}`);
+
+ // Each progress emission is fired at a batch edge, which is exactly
+ // where the main-thread fallback awaits its `setTimeout(0)` yielder.
+ // Counting ticks deterministically proves the build loop yields
+ // enough times to give the event loop breathing room (the worker
+ // path is off-thread so this contract is what the fallback relies on).
+ let ticks = 0;
+ await buildMerkleTreeAsync(items, {
+ onProgress: () => {
+ ticks += 1;
+ }
+ });
+
+ // 256 leaves: 4 leaf-batch edges + several in-layer tick boundaries
+ // = at least 9 ticks in practice; we assert 4 as a broad floor so the
+ // test stays stable if batching changes.
+ expect(ticks).toBeGreaterThanOrEqual(4);
+ });
+
+ it('rejects with AbortError when the signal aborts during the fallback walk', async () => {
+ __setWorkerFactoryForTests(null);
+ const ctrl = new AbortController();
+ const items: BytesLike[] = Array.from({ length: 256 }, (_, i) => `i-${i}`);
+ const promise = buildMerkleTreeAsync(items, { signal: ctrl.signal });
+ queueMicrotask(() => ctrl.abort());
+ await expect(promise).rejects.toThrow(/abort/i);
+ });
+});
diff --git a/lib/merkleClient.ts b/lib/merkleClient.ts
new file mode 100644
index 0000000..b288414
--- /dev/null
+++ b/lib/merkleClient.ts
@@ -0,0 +1,180 @@
+import { buildMerkleTree, type BytesLike, type MerkleTree } from './merkle';
+
+/**
+ * Promise-based client for off-main-thread Merkle tree hashing.
+ *
+ * - When a Web Worker is available AND the caller didn't opt out via
+ * `useWorker: false`, the work is dispatched to a Dedicated Worker
+ * loaded from `./workers/crypto.worker.ts`. The main thread stays
+ * free, so 10,000 keccak hashes don't lock the UI — the issue's
+ * acceptance criterion.
+ * - When `Worker` is undefined (very old browsers, sandboxed iframes,
+ * SSR, tests), we fall back to running the same `buildMerkleTree` on
+ * the main thread. To preserve responsiveness we pass a `setTimeout(0)`
+ * yielder into `buildMerkleTree.shouldYield` so paint cycles slip
+ * in between hash batches — matching the worker-path UX without a
+ * worker.
+ * - `AbortSignal` cancels in-flight work in both paths; both reject
+ * with `DOMException('AbortError')`.
+ */
+
+export interface BuildOptions {
+ onProgress?: (processed: number, total: number) => void;
+ signal?: AbortSignal;
+ /** Force-disable worker dispatch even if the runtime supports it. */
+ useWorker?: boolean;
+}
+
+/**
+ * Abstract Worker surface used by the client. The real `Worker` matches
+ * this; tests inject a mock that records posted messages and synthesises
+ * inbound events without spinning up a real worker.
+ */
+export interface WorkerLike {
+ postMessage: (message: unknown) => void;
+ addEventListener: (type: 'message', listener: (event: MessageEvent) => void) => void;
+ removeEventListener: (type: 'message', listener: (event: MessageEvent) => void) => void;
+ terminate: () => void;
+}
+
+export type WorkerFactory = () => WorkerLike;
+
+/** Detect at module-init whether a real Web Worker is constructible. */
+export function supportsWorkers(): boolean {
+ return typeof Worker !== 'undefined';
+}
+
+/**
+ * Build the default factory lazily so static analysers in non-webpack
+ * runners (e.g. Vitest) don't trip on the worker import during non-
+ * browser test runs. The factory body is the only place `new Worker`
+ * appears in this file.
+ */
+const defaultWorkerFactory: WorkerFactory | null = (() => {
+ if (typeof Worker === 'undefined') return null;
+ return () => {
+ const worker = new Worker(new URL('./workers/crypto.worker.ts', import.meta.url), {
+ type: 'module'
+ });
+ return worker as unknown as WorkerLike;
+ };
+})();
+
+/** Mutable so tests can substitute a mock factory. */
+let activeWorkerFactory: WorkerFactory | null = defaultWorkerFactory;
+
+/**
+ * @internal
+ * Test-only override for the worker factory. Production callers ignore it.
+ */
+export function __setWorkerFactoryForTests(factory: WorkerFactory | null): void {
+ activeWorkerFactory = factory;
+}
+
+/**
+ * Macrotask yield used by the main-thread fallback path. setTimeout(0)
+ * is widely supported (unlike `scheduler.yield`) and reliably lets the
+ * event loop repaint between hash batches.
+ */
+function setTimeoutYield(): Promise {
+ return new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+/**
+ * Build a Merkle tree, choosing worker or main-thread execution at
+ * runtime. Resolves with the layered tree; rejects with
+ * `DOMException('AbortError')` if `signal` aborts.
+ */
+export async function buildMerkleTreeAsync(
+ items: BytesLike[],
+ options: BuildOptions = {}
+): Promise {
+ const { onProgress, signal, useWorker = true } = options;
+
+ if (signal?.aborted) {
+ throw new DOMException('Merkle build aborted', 'AbortError');
+ }
+
+ // Dispatch decision. We trust the existence of an active factory
+ // rather than re-sniffing `typeof Worker`: tests inject mock factories
+ // even in Node where the global is undefined, and only a real browser
+ // ever installs the default factory in the first place.
+ const factory = activeWorkerFactory;
+ if (useWorker && factory !== null) {
+ return runOnWorker(items, onProgress, signal, factory);
+ }
+
+ // Fallback path: same algorithm, but with a setTimeout yielder so the
+ // main thread stays responsive during the heavy hashing.
+ return buildMerkleTree(items, {
+ batchSize: 64,
+ shouldYield: setTimeoutYield,
+ signal,
+ onProgress
+ });
+}
+
+async function runOnWorker(
+ items: BytesLike[],
+ onProgress: BuildOptions['onProgress'],
+ signal: AbortSignal | undefined,
+ factory: WorkerFactory
+): Promise {
+ const jobId = nextJobId();
+ return new Promise((resolve, reject) => {
+ const worker = factory();
+ let settled = false;
+
+ const finish = (fn: () => void) => {
+ if (settled) return;
+ settled = true;
+ try {
+ worker.removeEventListener('message', onMessage);
+ } catch {
+ /* worker may already be gone */
+ }
+ signal?.removeEventListener('abort', onAbort);
+ worker.terminate();
+ fn();
+ };
+
+ const onAbort = () => {
+ finish(() => reject(new DOMException('Merkle build aborted', 'AbortError')));
+ };
+
+ const onMessage = (event: MessageEvent) => {
+ const msg = event.data as
+ | { type: 'progress'; jobId: number; processed: number; total: number }
+ | { type: 'result'; jobId: number; tree: MerkleTree }
+ | { type: 'error'; jobId: number; message: string }
+ | undefined;
+ if (!msg || msg.jobId !== jobId) return;
+ if (msg.type === 'progress') {
+ onProgress?.(msg.processed, msg.total);
+ } else if (msg.type === 'result') {
+ finish(() => resolve(msg.tree));
+ } else if (msg.type === 'error') {
+ finish(() => reject(new Error(msg.message)));
+ }
+ };
+
+ worker.addEventListener('message', onMessage);
+
+ if (signal) {
+ if (signal.aborted) {
+ finish(() => reject(new DOMException('Merkle build aborted', 'AbortError')));
+ return;
+ }
+ signal.addEventListener('abort', onAbort, { once: true });
+ }
+
+ worker.postMessage({ type: 'build', jobId, items });
+ });
+}
+
+/** Monotonic job id so concurrent worker callbacks don't cross-talk. */
+let lastJobId = 0;
+function nextJobId(): number {
+ lastJobId = (lastJobId + 1) >>> 0;
+ return lastJobId;
+}
diff --git a/lib/workers/crypto.worker.ts b/lib/workers/crypto.worker.ts
new file mode 100644
index 0000000..746eadb
--- /dev/null
+++ b/lib/workers/crypto.worker.ts
@@ -0,0 +1,73 @@
+///
+
+/**
+ * crypto.worker — Dedicated Web Worker for Merkle-tree hashing.
+ *
+ * Spun up by `lib/merkleClient.ts` via the standard
+ * new Worker(new URL('./workers/crypto.worker.ts', import.meta.url))
+ * pattern, which Next.js 14's webpack 5 splits into its own chunk that
+ * is only fetched when the worker is actually instantiated.
+ *
+ * Lives outside the main thread so 10,000 keccak hashes don't lock the
+ * UI. Communicates with the main thread over a typed message protocol:
+ *
+ * main → worker : { type: 'build', jobId, items, batchSize? }
+ * worker → main : { type: 'progress', jobId, processed, total }
+ * | { type: 'result', jobId, tree }
+ * | { type: 'error', jobId, message }
+ *
+ * Note: `items` here are un-hashed `BytesLike` payloads; the worker
+ * hashes them itself so the hashing cost is off-thread. Pre-hashing in
+ * the main thread would be wasted work.
+ */
+
+import { buildMerkleTree, type BytesLike, type MerkleTree } from '../merkle';
+
+declare const self: DedicatedWorkerGlobalScope;
+
+export interface WorkerBuildRequest {
+ type: 'build';
+ jobId: number;
+ items: BytesLike[];
+ batchSize?: number;
+}
+
+export type WorkerOutMessage =
+ | { type: 'progress'; jobId: number; processed: number; total: number }
+ | { type: 'result'; jobId: number; tree: MerkleTree }
+ | { type: 'error'; jobId: number; message: string };
+
+self.addEventListener('message', async (event: MessageEvent) => {
+ const msg = event.data;
+ if (!msg || msg.type !== 'build') return;
+
+ try {
+ // No shouldYield here — we're already off the main thread, so the
+ // microtask yield inside buildMerkleTree is essentially free, and
+ // skipping it keeps the worker optimal. Progress still streams.
+ const tree = await buildMerkleTree(msg.items, {
+ batchSize: msg.batchSize ?? 64,
+ onProgress: (processed, total) => {
+ const out: WorkerOutMessage = {
+ type: 'progress',
+ jobId: msg.jobId,
+ processed,
+ total
+ };
+ self.postMessage(out);
+ }
+ });
+
+ const result: WorkerOutMessage = { type: 'result', jobId: msg.jobId, tree };
+ self.postMessage(result);
+ } catch (err) {
+ const out: WorkerOutMessage = {
+ type: 'error',
+ jobId: msg.jobId,
+ message: err instanceof Error ? err.message : String(err)
+ };
+ self.postMessage(out);
+ }
+});
+
+export {}; // ensure module scope
diff --git a/package.json b/package.json
index 6165fd7..c303850 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"lint": "next lint",
"typecheck": "tsc --noEmit",
"size": "node scripts/check-bundle-budget.mjs",
- "test:bundle": "node --test scripts/check-bundle-budget.test.mjs"
+ "test:bundle": "node --test scripts/check-bundle-budget.test.mjs",
"test": "vitest run"
},
"dependencies": {