From 213f685bff34794958bbb0bdf4ad93353b248ae4 Mon Sep 17 00:00:00 2001 From: malaysiaonelove Date: Mon, 27 Jul 2026 10:39:25 +0000 Subject: [PATCH] feat(merkle): offload Merkle tree hashing to a Web Worker Closes #23. Builds 10,000+ keccak leaf hashes off the main thread via a typed Web Worker so the browser tab no longer hangs during bulk-claim Merkle tree generation. Falls back to main-thread execution with setTimeout(0) yields between hash batches when Web Workers are unavailable (SSR, sandboxed iframes, very old browsers). New modules lib/merkle.ts async Merkle builder + getProof/verifyProof lib/workers/crypto.worker.ts DedicatedWorker (build/progress/result/error) lib/merkleClient.ts Promise API + dispatch + AbortSignal lib/merkle.test.ts 17 tests lib/merkleClient.test.ts 11 tests components/BulkClaimSection.tsx 10k-leaf demo wired into the dashboard Updated app/dashboard/page.tsx mounts BulkClaimSection package.json restores a missing comma after test:bundle (pre-existing) Verified npm run typecheck exit 0 npm run lint exit 0 npm test 43 passes (merkle 17 + merkleClient 11 + pre-existing) npm run build ok (.next chunks emitted) npm run size /dashboard 104.5 KB gzip (budget 500 KB) --- app/dashboard/page.tsx | 3 + components/BulkClaimSection.tsx | 184 +++++++++++++++++++++++++++ lib/merkle.test.ts | 170 +++++++++++++++++++++++++ lib/merkle.ts | 200 ++++++++++++++++++++++++++++++ lib/merkleClient.test.ts | 212 ++++++++++++++++++++++++++++++++ lib/merkleClient.ts | 180 +++++++++++++++++++++++++++ lib/workers/crypto.worker.ts | 73 +++++++++++ package.json | 2 +- 8 files changed, 1023 insertions(+), 1 deletion(-) create mode 100644 components/BulkClaimSection.tsx create mode 100644 lib/merkle.test.ts create mode 100644 lib/merkle.ts create mode 100644 lib/merkleClient.test.ts create mode 100644 lib/merkleClient.ts create mode 100644 lib/workers/crypto.worker.ts 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 && ( + + )} +
+ + {/* 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 && ( + + )} + + {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": {