diff --git a/README.md b/README.md index 8b994d6..c21a4e8 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ _| _| _| _| _| _| _| _| _| _| _| _| [![CI](https://github.com/halaprix/domino/actions/workflows/ci.yml/badge.svg)](https://github.com/halaprix/domino/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/@halaprix/domino)](https://www.npmjs.com/package/@halaprix/domino) -[![bundle size](https://img.shields.io/badge/gzip-13.9KB-brightgreen)](https://www.npmjs.com/package/@halaprix/domino) +[![bundle size](https://img.shields.io/badge/gzip-17.1KB-brightgreen)](https://www.npmjs.com/package/@halaprix/domino) [![TypeScript](https://img.shields.io/badge/TypeScript-5.5-blue)](https://www.typescriptlang.org/) [![MIT License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) diff --git a/src/__tests__/bundle-size.test.ts b/src/__tests__/bundle-size.test.ts index 8227e84..f759e1b 100644 --- a/src/__tests__/bundle-size.test.ts +++ b/src/__tests__/bundle-size.test.ts @@ -97,6 +97,40 @@ * comparisons). * * Engine subpaths (viem, ethers-v5, ethers-v6) removed in v2. + * + * 1.3 (F9 `MultichainResolver`): ceiling raised, 15KB → **18KB** gzip. + * `MultichainResolver` (`src/engine/multichain.ts`) is a legitimate new + * feature class — parallel per-chain fan-out over the existing single-chain + * runners, a constructor discriminating/lazily-wrapping `StepExecutor` vs. + * `Eip1193Provider` entries, and the [v5] flattened cross-chain duplicate- + * instance guard — not bloat to trim. This test measures the WHOLE bundled + * artifact, but `"sideEffects": false` (package.json) lets a tree-shaking + * consumer's bundler drop `MultichainResolver` entirely if unused, so the + * ceiling here is a whole-library budget, not a per-consumer cost — a + * consumer who never imports it pays nothing for it. 18KB (not the ~16.7KB + * this feature alone needs) deliberately leaves ~1.3KB of headroom for the + * remaining 1.3 work (G1's handler migration is expected to be roughly + * size-neutral: old handler implementations leave the bundle as new ones + * enter). Measured delta: + * before: 56,205 bytes (54.89KB) gzip 14,282 bytes (13.95KB) + * after: 65,226 bytes (63.70KB) gzip 17,065 bytes (16.67KB) + * delta: +9,021 bytes raw (+8.81KB) +2,783 bytes gzip (+2.72KB) + * + * 1.3 (F9 external-review round — 2 accepted findings, same 18KB ceiling): + * (P1) `snapshot()`'s `getBlockNumber()` calls are now wrapped in + * `Promise.resolve().then(...)` — a non-conforming custom executor that + * throws SYNCHRONOUSLY (instead of rejecting a promise) used to abort the + * `.map()` mid-iteration, discarding an earlier chain's already-created + * promise with no handler ever attached to it (a real, reproduced + * unhandled-rejection leak, not hypothetical). (P2) the flattened + * cross-chain duplicate scan (`assertNoFlattenedDuplicates`) now calls a + * shared `isSingleUseTask()` predicate (`src/core/internal.ts`) instead of + * re-implementing the brand check inline — `rejectDuplicateInstances`/ + * `markTasksConsumed` now call the same predicate too, replacing the inline + * `Branded`-cast pattern those two used before. Measured delta: + * before: 65,226 bytes (63.70KB) gzip 17,065 bytes (16.67KB) + * after: 66,345 bytes (64.79KB) gzip 17,533 bytes (17.12KB) + * delta: +1,119 bytes raw (+1.09KB) +468 bytes gzip (+0.46KB) */ import { describe, expect, it } from 'vitest' @@ -116,14 +150,17 @@ function bundleSizeGzip(name: string): number { } describe('bundle size', () => { - it('main index bundle is under 15KB gzip (gzip-only budget for consumer experience)', () => { + it('main index bundle is under 18KB gzip (gzip-only budget for consumer experience)', () => { const sizeGzip = bundleSizeGzip('index.js') // Budget switched to gzip-only: what consumers actually download (transfer size). // All features included: viem ABI utils + bytecodes + core/handlers + // defineTask + hardening + single-use guard + F3 human-readable ABI + - // P1 review fixes. Gzip is the metric that matters; descriptive naming - // in production code no longer constrained by raw-byte budget. - expect(sizeGzip).toBeLessThan(15 * 1024) + // P1 review fixes + F9 MultichainResolver. Gzip is the metric that + // matters; descriptive naming in production code no longer constrained + // by raw-byte budget. Ceiling raised 15KB -> 18KB for F9 — see the + // module doc comment's "1.3 (F9 MultichainResolver)" entry for the + // measured delta and why 18KB (not just-enough) was chosen. + expect(sizeGzip).toBeLessThan(18 * 1024) }) it('no engine subpaths exist (removed in v2)', () => { diff --git a/src/__tests__/multichain.test.ts b/src/__tests__/multichain.test.ts new file mode 100644 index 0000000..6d722ba --- /dev/null +++ b/src/__tests__/multichain.test.ts @@ -0,0 +1,567 @@ +import { describe, it, expect, vi } from 'vitest' +import { defineTask } from '../core/defineTask' +import { DominoTaskReuseError } from '../core/errors' +import { Presets } from '../core/presets' +import { MultichainResolver } from '../engine/multichain' +import type { + Address, + MultistepTask, + StepCall, + StepExecutor, + RawResult, + BlockParam, + PinnedBlock, +} from '../core/types' + +/** + * F9 — `MultichainResolver` (T19). + * + * Pipeline under test (see `src/engine/multichain.ts`): + * #assertKnownPlanChainIds -> assertNoFlattenedDuplicates([v5]) -> per-chain + * runMultistepTasks/runSettled, dispatched concurrently. + * + * [v5]'s flattened duplicate check is the one genuinely new safety rule this + * feature adds — everything else is fan-out/fan-in over the existing + * single-chain runners, which already have their own test coverage + * (`singleUse.test.ts`, `pinBlock.test.ts`, `concurrency.test.ts`, etc.). + */ + +const ADDR = '0xA0b86991c6218b36c1d19D4a2e9Eb004C35d5Cc4' as Address + +const testAbi = [ + { + type: 'function', + name: 'getNum', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'uint256' }], + }, +] as const + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** Fresh single-call `defineTask` task — branded (single-use). */ +function brandedTask(): MultistepTask { + return defineTask((t) => t.call({ target: ADDR, abi: testAbi, functionName: 'getNum' })) +} + +/** Legacy (unbranded) one-step task, stateless — reusable by 1.0 rules. */ +function legacyTask(value = 'CONST'): MultistepTask { + return { + maxStep: 1, + buildStepCalls(step) { + if (step !== 1) return [] + return [{ key: 'v', target: ADDR, abi: testAbi, functionName: 'getNum' }] + }, + consumeStepResults() { + // stateless + }, + finalize() { + return value + }, + } +} + +/** Records every `executeMulticall` invocation (calls + block param). */ +interface TrackingExecutor extends StepExecutor { + invocations: { calls: StepCall[]; block?: BlockParam }[] +} + +function makeExecutor( + opts: { + getBlockNumber?: (block?: BlockParam) => Promise + fail?: unknown + delayMs?: number + onCall?: () => void + } = {}, +): TrackingExecutor { + const invocations: { calls: StepCall[]; block?: BlockParam }[] = [] + const executor: TrackingExecutor = { + invocations, + async executeMulticall(calls: StepCall[], block?: BlockParam): Promise { + opts.onCall?.() + invocations.push({ calls, ...(block !== undefined ? { block } : {}) }) + if (opts.delayMs) await sleep(opts.delayMs) + if (opts.fail !== undefined) throw opts.fail + return calls.map((): RawResult => ({ status: 'success', value: 1n })) + }, + } + if (opts.getBlockNumber) { + executor.getBlockNumber = vi.fn(opts.getBlockNumber) + } + return executor +} + +// ─── 1. Constructor discrimination ─────────────────────────────────────── + +describe('MultichainResolver — constructor', () => { + it('provider entry: lazily wrapped (no request before first use) and wrapped exactly once (cached across repeated use)', async () => { + const requestLog: string[] = [] + const provider = { + request: vi.fn(async ({ method }: { method: string; params?: readonly unknown[] }) => { + requestLog.push(method) + if (method === 'eth_getBlockByNumber') return { number: '0x64' } + throw new Error(`unexpected method ${method}`) + }), + } + + const resolver = new MultichainResolver({ 1: provider }) + // Construction alone must not touch the provider at all. + expect(provider.request).not.toHaveBeenCalled() + + // Two separate operations that each resolve chain 1's executor. + await resolver.chain(1).executor.getBlockNumber?.() + await resolver.snapshot() + + // If a fresh Eip1193Executor were constructed on each access, its + // internal getBlockNumber wouldn't share any cache — but getBlockNumber + // always calls eth_getBlockByNumber regardless, so what actually proves + // single-wrapping here is identity: the exact same executor object came + // back both times. + const first = resolver.chain(1).executor + const second = resolver.chain(1).executor + expect(first).toBe(second) + expect(requestLog.filter((m) => m === 'eth_getBlockByNumber')).toHaveLength(2) + }) + + it('executor entry: used as-is (identity preserved, never wrapped)', () => { + const executor = makeExecutor() + const resolver = new MultichainResolver({ 1: executor }) + expect(resolver.chain(1).executor).toBe(executor) + }) + + it('garbage entry (neither executeMulticall nor request) throws', () => { + expect(() => new MultichainResolver({ 1: {} as unknown as StepExecutor })).toThrow() + expect(() => new MultichainResolver({ 1: { foo: 'bar' } as unknown as StepExecutor })).toThrow() + }) + + it('empty chains record throws', () => { + expect(() => new MultichainResolver({})).toThrow() + }) +}) + +// ─── 2. chain() ─────────────────────────────────────────────────────────── + +describe('MultichainResolver — chain()', () => { + it('returns a cached MulticallResolver — identical instance across calls', () => { + const resolver = new MultichainResolver({ 1: makeExecutor(), 137: makeExecutor() }) + expect(resolver.chain(1)).toBe(resolver.chain(1)) + expect(resolver.chain(137)).toBe(resolver.chain(137)) + expect(resolver.chain(1)).not.toBe(resolver.chain(137)) + }) + + it('unknown chainId throws, listing known ids', () => { + const resolver = new MultichainResolver({ 1: makeExecutor(), 137: makeExecutor() }) + expect(() => resolver.chain(999)).toThrow(/999/) + expect(() => resolver.chain(999)).toThrow(/1/) + expect(() => resolver.chain(999)).toThrow(/137/) + }) +}) + +// ─── 3. snapshot() ──────────────────────────────────────────────────────── + +describe('MultichainResolver — snapshot()', () => { + it('resolves a correct chainId -> blockNumber map', async () => { + const resolver = new MultichainResolver({ + 1: makeExecutor({ getBlockNumber: async () => 100n }), + 137: makeExecutor({ getBlockNumber: async () => 200n }), + }) + + const snap = await resolver.snapshot() + expect(snap).toEqual({ 1: 100n, 137: 200n }) + }) + + it('runs getBlockNumber for every chain in parallel (overlapping in-flight)', async () => { + let inFlight = 0 + let maxInFlight = 0 + const events: string[] = [] + + function delayedGetBlockNumber(label: string, value: bigint) { + return async (): Promise => { + inFlight++ + maxInFlight = Math.max(maxInFlight, inFlight) + events.push(`${label}:start`) + await sleep(20) + inFlight-- + events.push(`${label}:end`) + return value + } + } + + const resolver = new MultichainResolver({ + 1: makeExecutor({ getBlockNumber: delayedGetBlockNumber('a', 111n) }), + 2: makeExecutor({ getBlockNumber: delayedGetBlockNumber('b', 222n) }), + }) + + const snap = await resolver.snapshot() + expect(snap).toEqual({ 1: 111n, 2: 222n }) + expect(maxInFlight).toBe(2) + // Both starts happen before either end — proves overlap, not serial dispatch. + expect(events.indexOf('a:start')).toBeLessThan(events.indexOf('b:end')) + expect(events.indexOf('b:start')).toBeLessThan(events.indexOf('a:end')) + }) + + it('one chain missing getBlockNumber -> throws before ANY RPC (zero requests across all chains)', async () => { + const requestSpy = vi.fn(async () => ({ number: '0x64' })) + const goodProvider = { request: requestSpy } + const badExecutor = makeExecutor() // no getBlockNumber at all + + const resolver = new MultichainResolver({ 1: goodProvider, 2: badExecutor }) + + await expect(resolver.snapshot()).rejects.toThrow(/getBlockNumber/) + expect(requestSpy).not.toHaveBeenCalled() + }) + + it('one chain rejects -> snapshot rejects with that error; no unhandled rejections (global guard)', async () => { + const boom = new Error('chain 2 getBlockNumber failed') + const resolver = new MultichainResolver({ + 1: makeExecutor({ getBlockNumber: async () => 100n }), + 2: makeExecutor({ + getBlockNumber: async () => { + throw boom + }, + }), + }) + + await expect(resolver.snapshot()).rejects.toBe(boom) + // Global unhandledRejection guard (src/__tests__/setup/unhandled-rejections.ts) + // fails this test itself if chain 1's already-resolved promise, or any + // derived promise, ever leaks. + }) + + // External review, P1: `.map()` used to invoke `executor.getBlockNumber()` + // directly. A non-conforming custom executor whose `getBlockNumber` throws + // SYNCHRONOUSLY (instead of returning a rejected promise) would abort that + // `.map()` call mid-iteration — discarding any promise an EARLIER chain's + // (conforming) call already created, with no handler ever attached to it. + // If that earlier promise rejects later, it leaks as an unhandled + // rejection. Fixed by deferring every call through + // `Promise.resolve().then(...)` so `.map()` itself can never throw. + it('P1 regression: chain A returns a later-rejecting promise, chain B throws synchronously -> snapshot rejects deterministically; no unhandled rejections', async () => { + const boomA = new Error('chain 1 getBlockNumber rejected later') + const boomB = new Error('chain 2 getBlockNumber threw synchronously') + + // Deliberately NOT `makeExecutor` here: `vi.fn`'s own internal + // settlement tracking attaches a handler to any promise a mocked + // implementation returns, which silently masks exactly the + // unhandled-rejection bug this test exists to catch (verified directly: + // a `vi.fn`-wrapped executor could NOT reproduce the leak even against + // the pre-fix code). Plain, unmocked executor objects only, so the + // global guard is actually exercising real promise-handling behavior. + const execA: StepExecutor = { + async executeMulticall(calls) { + return calls.map((): RawResult => ({ status: 'success', value: 1n })) + }, + getBlockNumber(): Promise { + return new Promise((_resolve, reject) => { + setTimeout(() => reject(boomA), 10) + }) + }, + } + + // Deliberately non-conforming: throws synchronously instead of + // returning a rejected promise (the type signature says `Promise`, + // but nothing at runtime enforces an `async` implementation). + const execB: StepExecutor = { + async executeMulticall(calls) { + return calls.map((): RawResult => ({ status: 'success', value: 1n })) + }, + getBlockNumber(): Promise { + throw boomB + }, + } + + const resolver = new MultichainResolver({ 1: execA, 2: execB }) + + // Chain B's synchronous throw settles on the very next microtask; chain + // A's rejection only lands after a real 10ms timer — microtasks always + // drain before the next macrotask, so which error wins is deterministic + // regardless of how the two chains happen to be ordered internally. + await expect(resolver.snapshot()).rejects.toBe(boomB) + + // Let chain A's delayed rejection actually land. Before the P1 fix, its + // promise (created, then discarded when `.map()` aborted on chain B's + // synchronous throw) would never have had a handler attached — the + // global unhandledRejection guard would fail THIS test once it fires. + await sleep(20) + }) +}) + +// ─── 4. [v5] Flattened duplicate-instance validation ───────────────────── + +describe('MultichainResolver — [v5] flattened duplicate-instance validation', () => { + it('same branded instance under two different chains -> DominoTaskReuseError before any executeMulticall; task still runnable afterward', async () => { + const execA = makeExecutor() + const execB = makeExecutor() + const resolver = new MultichainResolver({ 1: execA, 2: execB }) + + const shared = brandedTask() + + await expect(resolver.runAll({ 1: [shared], 2: [shared] })).rejects.toThrow(DominoTaskReuseError) + expect(execA.invocations).toHaveLength(0) + expect(execB.invocations).toHaveLength(0) + + // Nothing was consumed — the same instance runs fine afterward, alone. + const result = await resolver.chain(1).run([shared]) + expect(result).toEqual([1n]) + }) + + it('same branded instance twice in ONE chain array -> DominoTaskReuseError before any executeMulticall; still runnable afterward', async () => { + const execA = makeExecutor() + const resolver = new MultichainResolver({ 1: execA }) + const shared = brandedTask() + + await expect(resolver.runAll({ 1: [shared, shared] })).rejects.toThrow(DominoTaskReuseError) + expect(execA.invocations).toHaveLength(0) + + const result = await resolver.chain(1).run([shared]) + expect(result).toEqual([1n]) + }) + + it('runAllSettled: same branded instance across two chains also throws pre-execution (programmer error, not a settled rejection)', async () => { + const execA = makeExecutor() + const execB = makeExecutor() + const resolver = new MultichainResolver({ 1: execA, 2: execB }) + const shared = brandedTask() + + await expect(resolver.runAllSettled({ 1: [shared], 2: [shared] })).rejects.toThrow(DominoTaskReuseError) + expect(execA.invocations).toHaveLength(0) + expect(execB.invocations).toHaveLength(0) + }) + + it('legacy (unbranded) duplicate instance shared across two chains is ALLOWED (1.0 rule) — both chains complete normally', async () => { + const execA = makeExecutor() + const execB = makeExecutor() + const resolver = new MultichainResolver({ 1: execA, 2: execB }) + const shared = legacyTask('X') + + const result = await resolver.runAll({ 1: [shared], 2: [shared] }) + expect(result).toEqual({ 1: ['X'], 2: ['X'] }) + }) + + it('legacy (unbranded) duplicate instance twice in ONE chain array is ALLOWED (1.0 rule)', async () => { + const execA = makeExecutor() + const resolver = new MultichainResolver({ 1: execA }) + const shared = legacyTask('Y') + + const result = await resolver.runAll({ 1: [shared, shared] }) + expect(result).toEqual({ 1: ['Y', 'Y'] }) + }) +}) + +// ─── 5. runAll ──────────────────────────────────────────────────────────── + +describe('MultichainResolver — runAll()', () => { + it('two chains run concurrently (overlapping in-flight) and results are keyed correctly', async () => { + let inFlight = 0 + let maxInFlight = 0 + + function makeSlowExecutor(): StepExecutor { + return { + async executeMulticall(calls: StepCall[]): Promise { + inFlight++ + maxInFlight = Math.max(maxInFlight, inFlight) + await sleep(20) + inFlight-- + return calls.map((): RawResult => ({ status: 'success', value: 1n })) + }, + } + } + + const resolver = new MultichainResolver({ 1: makeSlowExecutor(), 2: makeSlowExecutor() }) + + const result = await resolver.runAll({ 1: [legacyTask('a')], 2: [legacyTask('b')] }) + expect(result).toEqual({ 1: ['a'], 2: ['b'] }) + expect(maxInFlight).toBe(2) + }) + + it('per-chain `blocks` override reaches the right executor; chains without an override get `options.block`', async () => { + const execA = makeExecutor() + const execB = makeExecutor() + const resolver = new MultichainResolver({ 1: execA, 2: execB }) + + await resolver.runAll( + { 1: [legacyTask('a')], 2: [legacyTask('b')] }, + { + block: { blockNumber: 1000n }, + blocks: { 1: { blockNumber: 42n } }, + }, + ) + + expect(execA.invocations[0]!.block).toEqual({ blockNumber: 42n }) + expect(execB.invocations[0]!.block).toEqual({ blockNumber: 1000n }) + }) + + it('unknown plan chainId throws before any chain executes', async () => { + const execA = makeExecutor() + const resolver = new MultichainResolver({ 1: execA }) + + await expect(resolver.runAll({ 1: [legacyTask()], 999: [legacyTask()] })).rejects.toThrow(/999/) + expect(execA.invocations).toHaveLength(0) + }) + + it('empty plan resolves to {}', async () => { + const resolver = new MultichainResolver({ 1: makeExecutor() }) + expect(await resolver.runAll({})).toEqual({}) + }) +}) + +// ─── 6. runAll failure policy ───────────────────────────────────────────── + +describe('MultichainResolver — runAll() failure policy', () => { + it('chain A (lower id) ok, chain B rejects -> runAll rejects with B\'s error', async () => { + const boom = new Error('chain 2 transport failure') + const resolver = new MultichainResolver({ + 1: makeExecutor(), + 2: makeExecutor({ fail: boom }), + }) + + await expect(resolver.runAll({ 1: [legacyTask()], 2: [legacyTask()] })).rejects.toBe(boom) + }) + + it('BOTH chains reject -> rejects with the LOWEST chainId\'s error, deterministically, with zero unhandled rejections', async () => { + const boomLow = new Error('chain 1 failure') + const boomHigh = new Error('chain 2 failure') + + for (let i = 0; i < 5; i++) { + const resolver = new MultichainResolver({ + 1: makeExecutor({ fail: boomLow, delayMs: 5 }), + 2: makeExecutor({ fail: boomHigh, delayMs: 15 }), + }) + + await expect(resolver.runAll({ 1: [legacyTask()], 2: [legacyTask()] })).rejects.toBe(boomLow) + } + + // Reverse the relative timing (chain 1 slower than chain 2) — the + // SELECTION rule is by chainId, not by which settles first. + for (let i = 0; i < 5; i++) { + const resolver = new MultichainResolver({ + 1: makeExecutor({ fail: boomLow, delayMs: 15 }), + 2: makeExecutor({ fail: boomHigh, delayMs: 5 }), + }) + + await expect(resolver.runAll({ 1: [legacyTask()], 2: [legacyTask()] })).rejects.toBe(boomLow) + } + }) +}) + +// ─── 7. runAllSettled ────────────────────────────────────────────────────── + +describe('MultichainResolver — runAllSettled()', () => { + it('returns per-chain settled arrays', async () => { + const resolver = new MultichainResolver({ + 1: makeExecutor(), + 2: makeExecutor(), + }) + + const result = await resolver.runAllSettled({ + 1: [brandedTask()], + 2: [brandedTask()], + }) + + expect(result[1]).toEqual([{ status: 'fulfilled', value: 1n, diagnostics: { optionalFailures: [] } }]) + expect(result[2]).toEqual([{ status: 'fulfilled', value: 1n, diagnostics: { optionalFailures: [] } }]) + }) + + it('one chain\'s batch failure carries kind-\'batch\' failures for that chain only; the other chain is unaffected', async () => { + const boom = new Error('chain 2 transport failure') + const resolver = new MultichainResolver({ + 1: makeExecutor(), + 2: makeExecutor({ fail: boom }), + }) + + const result = await resolver.runAllSettled({ + 1: [brandedTask()], + 2: [brandedTask()], + }) + + expect(result[1]![0]).toEqual({ status: 'fulfilled', value: 1n, diagnostics: { optionalFailures: [] } }) + + const settledB = result[2]![0]! + expect(settledB.status).toBe('rejected') + if (settledB.status === 'rejected') { + // The task's finalize() throws once its single ref sees a DominoCallError + // (kind 'batch') — asserting the underlying cause is the transport error + // is enough to prove batch-failure routing reached this task. + expect(String(settledB.error)).toMatch(/./) + } + }) +}) + +// ─── 8. pinBlock + onPin: once per chain ────────────────────────────────── + +describe('MultichainResolver — pinBlock + onPin', () => { + it('onPin fires exactly once per chain, each chain resolving via its own executor', async () => { + const pins: { chainId: number; block: PinnedBlock }[] = [] + + const resolver = new MultichainResolver({ + 1: makeExecutor({ getBlockNumber: async () => 111n }), + 2: makeExecutor({ getBlockNumber: async () => 222n }), + }) + + await resolver.runAll( + { 1: [legacyTask('a')], 2: [legacyTask('b')] }, + { + pinBlock: true, + onPin: (block) => { + // onPin itself carries no chainId — recovering it here (for the + // assertion only) relies on the resolved blockNumber being unique + // per chain in this fixture, exactly as the class's own doc + // comment describes as the limitation. + const chainId = 'blockNumber' in block && block.blockNumber === 111n ? 1 : 2 + pins.push({ chainId, block }) + }, + }, + ) + + expect(pins).toHaveLength(2) + expect(pins.map((p) => p.chainId).sort()).toEqual([1, 2]) + expect(pins.find((p) => p.chainId === 1)!.block).toEqual({ blockNumber: 111n }) + expect(pins.find((p) => p.chainId === 2)!.block).toEqual({ blockNumber: 222n }) + }) + + it('with `blocks` overrides, each chain pins its own override (no getBlockNumber RPC needed)', async () => { + const exec1 = makeExecutor({ getBlockNumber: async () => 999n }) + const exec2 = makeExecutor({ getBlockNumber: async () => 999n }) + const resolver = new MultichainResolver({ 1: exec1, 2: exec2 }) + + const pins: PinnedBlock[] = [] + + await resolver.runAll( + { 1: [legacyTask('a')], 2: [legacyTask('b')] }, + { + pinBlock: true, + blocks: { 1: { blockNumber: 10n }, 2: { blockNumber: 20n } }, + onPin: (block) => pins.push(block), + }, + ) + + expect(exec1.getBlockNumber).not.toHaveBeenCalled() + expect(exec2.getBlockNumber).not.toHaveBeenCalled() + expect(pins).toEqual( + expect.arrayContaining([{ blockNumber: 10n }, { blockNumber: 20n }]), + ) + expect(exec1.invocations[0]!.block).toEqual({ blockNumber: 10n }) + expect(exec2.invocations[0]!.block).toEqual({ blockNumber: 20n }) + }) +}) + +// ─── 9. Presets.throughput composition ───────────────────────────────────── + +describe('MultichainResolver — Presets.throughput composes with runAll options', () => { + it('{ ...Presets.throughput, blocks: {...} } runs to completion across chains', async () => { + const exec1 = makeExecutor() + const exec2 = makeExecutor() + const resolver = new MultichainResolver({ 1: exec1, 2: exec2 }) + + const result = await resolver.runAll( + { 1: [legacyTask('a')], 2: [legacyTask('b')] }, + { ...Presets.throughput, blocks: { 1: { blockNumber: 5n } } }, + ) + + expect(result).toEqual({ 1: ['a'], 2: ['b'] }) + expect(exec1.invocations[0]!.block).toEqual({ blockNumber: 5n }) + }) +}) diff --git a/src/core/internal.ts b/src/core/internal.ts index f28ab0c..08e93f3 100644 --- a/src/core/internal.ts +++ b/src/core/internal.ts @@ -33,9 +33,14 @@ * * **Naming:** below this point, local/parameter names are deliberately * terse (`t`/`ts`/`o` for task/tasks/options) — a legacy artifact of a - * retired raw-byte bundle budget. This module is 100% internal (never - * imported outside `defineTask.ts`/`erc20.ts`/`erc4626.ts`/the two runners), - * so future code should prefer descriptive names. + * retired raw-byte bundle budget. This module is 100% internal (imported + * only by `defineTask.ts`/`erc20.ts`/`erc4626.ts`/the two runners, plus + * `engine/multichain.ts` (F9) for `isSingleUseTask` — its flattened, + * cross-chain duplicate check shares that ONE predicate with + * `rejectDuplicateInstances` below rather than re-implementing the brand + * check independently (external review, P2), even though the surrounding + * scan loop itself is necessarily different — a whole plan of per-chain + * arrays, not one array), so future code should prefer descriptive names. */ import type { MultistepTask, StepExecutor, BlockParam, PinnedBlock } from './types' @@ -78,10 +83,28 @@ const consumed = new WeakSet() */ export const DEDUPE_ELIGIBLE: unique symbol = Symbol('domino.dedupeEligible') -/** TS-only convenience for the brand-check casts below — erased at compile - * time, so using it at 3 call sites (instead of a real `isBranded()` - * function) costs zero extra runtime bytes over one. */ -type Branded = MultistepTask & SingleUseCarrier +/** + * True iff `t` carries the internal `SINGLE_USE` brand — i.e. it was + * produced by `defineTask()`/`buildErc20Task()`/`buildErc4626Task()`, not + * hand-authored. The one true "is this task subject to the single-use guard + * at all" check — shared by `rejectDuplicateInstances` and + * `markTasksConsumed` below, AND by `MultichainResolver`'s flattened, + * cross-chain duplicate scan (`src/engine/multichain.ts`, F9's [v5] rule). + * One source of truth so none of the three can ever drift on what counts as + * "branded" (external review, P2 — the flattened scan originally + * re-implemented this exact check independently; a prior draft of THIS + * module also reasoned that inlining the cast at each call site, rather than + * a real predicate function, cost zero extra runtime bytes — true only while + * every call site lived in this one file. Once a 4th call site appeared in a + * different module, sharing the check outweighs that marginal byte saving). + * + * Untyped on purpose (`MultistepTask`, not `MultistepTask`): the + * check itself never touches `T` — it only reads a symbol-keyed property — + * so no call site needs to thread a type parameter through just to call this. + */ +export function isSingleUseTask(t: MultistepTask): boolean { + return Boolean((t as MultistepTask & SingleUseCarrier)[SINGLE_USE]) +} /** * Numeric (+ two boolean) options validated + defaulted by `validateOptions` @@ -195,7 +218,7 @@ export function validateOptions(o: NumericOptionsInput | undefined): ValidatedRu export function rejectDuplicateInstances(ts: MultistepTask[]): void { let seen: Set> | undefined for (const t of ts) { - if (!(t as Branded)[SINGLE_USE]) continue + if (!isSingleUseTask(t)) continue seen ??= new Set() if (seen.has(t)) { throw new DominoTaskReuseError( @@ -268,7 +291,7 @@ export function validatePinCapability(options: PinOptionsInput | undefined, exec */ export function markTasksConsumed(ts: MultistepTask[]): void { for (const t of ts) { - if ((t as Branded)[SINGLE_USE] && consumed.has(t)) { + if (isSingleUseTask(t) && consumed.has(t)) { throw new DominoTaskReuseError( 'Task instance already consumed: domino tasks are single-run — create a fresh task ' + 'for each run (factories such as buildErc20Task/defineTask return a fresh instance ' + @@ -276,7 +299,7 @@ export function markTasksConsumed(ts: MultistepTask[]): void { ) } } - for (const t of ts) if ((t as Branded)[SINGLE_USE]) consumed.add(t) + for (const t of ts) if (isSingleUseTask(t)) consumed.add(t) } /** diff --git a/src/engine/multichain.ts b/src/engine/multichain.ts new file mode 100644 index 0000000..0625e23 --- /dev/null +++ b/src/engine/multichain.ts @@ -0,0 +1,366 @@ +/** + * `MultichainResolver` (F9) — runs the same task-shaped work across several + * chains in parallel, each chain going through the existing single-chain + * runners (`runMultistepTasks`/`runSettled`) untouched. This module adds NO + * new execution machinery — it is purely a fan-out/fan-in layer over one + * `StepExecutor` (or lazily-wrapped `Eip1193Provider`) per chain. + * + * **[v5] Flattened duplicate-instance validation** is this module's one + * genuinely new safety rule (see `assertNoFlattenedDuplicates` below): a + * branded (single-use) task instance appearing more than once ANYWHERE in a + * `runAll`/`runAllSettled` plan — twice in one chain's array, or once each + * under two different chain ids — is rejected UP FRONT, before any chain's + * runner is invoked. Without this, two chains would race to consume the + * same branded instance (each runner's own `markTasksConsumed` only + * guards against a SECOND caller of `runMultistepTasks`/`runSettled`, not + * against two callers racing concurrently against the same instance) — + * whichever chain loses that race would throw `DominoTaskReuseError` + * mid-run, potentially leaving that chain's sibling tasks executed against + * a step loop that never reaches `finalize()`. Scanning the whole flattened + * plan first and consuming nothing on failure means every task, from every + * chain, is still fully resubmittable after the throw. + */ + +import type { Eip1193Provider, StepExecutor, MultistepTask, BlockParam } from '../core/types' +import { runMultistepTasks, type BatchOptions } from '../core/runMultistepTasks' +import { runSettled, type SettledTaskResult } from '../core/runSettled' +import { isSingleUseTask } from '../core/internal' +import { DominoTaskReuseError } from '../core/errors' +import { Eip1193Executor } from './eip1193' +import { MulticallResolver } from './resolver' + +/** `runAll`/`runAllSettled` options — `BatchOptions` plus a per-chain block override. */ +export interface MultichainRunOptions extends BatchOptions { + /** + * Per-chain block override — takes precedence over the top-level `block` + * for that one chain only. Chains with no entry here (or no `blocks` map + * at all) fall back to `options.block` exactly as a single-chain call + * would. + * + * Never forwarded to `runMultistepTasks`/`runSettled` itself — only the + * per-chain resolved `block` is (see `effectiveOptionsFor` below); a + * single-chain `StepExecutor`/`BatchOptions` consumer has no use for a + * map keyed by every OTHER chain's id. + */ + blocks?: Record +} + +function isStepExecutor(entry: Eip1193Provider | StepExecutor): entry is StepExecutor { + return typeof (entry as StepExecutor).executeMulticall === 'function' +} + +function isEip1193Provider(entry: Eip1193Provider | StepExecutor): entry is Eip1193Provider { + return typeof (entry as Eip1193Provider).request === 'function' +} + +/** + * [v5] Scans EVERY task array in `plan` — across ALL chains, not just one — + * for a branded (single-use) instance appearing more than once. Mirrors + * `rejectDuplicateInstances`'s approach in `src/core/internal.ts` exactly + * (single O(n) pass, a `Set` allocated lazily on the first branded task seen, + * legacy unbranded instances never checked/never throw — 1.0's "duplicate + * stateless task in one array" pattern stays supported) — just widened to + * treat the flattened, cross-chain plan as one array instead of one chain's. + * The "is this branded?" check itself is `isSingleUseTask` from + * `src/core/internal.ts` — shared with `rejectDuplicateInstances`/ + * `markTasksConsumed`, not re-implemented here (external review, P2), so this + * scan and the single-chain runners' own guard can never drift on what + * counts as "branded." + * + * Consumes nothing: this only READS the brand, it never marks anything + * consumed — every task in `plan`, including the two colliding instances + * themselves, remains fully resubmittable after this throws. + */ +function assertNoFlattenedDuplicates(plan: Record[]>): void { + let seen: Set> | undefined + for (const key of Object.keys(plan)) { + const chainId = Number(key) + const tasks = plan[chainId] ?? [] + for (const t of tasks) { + if (!isSingleUseTask(t)) continue + seen ??= new Set() + if (seen.has(t)) { + throw new DominoTaskReuseError( + 'Same task instance appears more than once in this multichain plan (either twice in one ' + + "chain's array, or once each under two different chain ids) — domino tasks are " + + 'single-run, and reusing one across chains racing in parallel could leave one chain ' + + 'mid-run when the other consumes it first; create a fresh task instance per chain entry', + ) + } + seen.add(t) + } + } +} + +/** + * Effective per-chain `BatchOptions`: `options.blocks?.[chainId]` wins over + * `options.block` for that one chain; every other field passes through + * unchanged. `blocks` itself is always stripped before this reaches + * `runMultistepTasks`/`runSettled` — those only know `BatchOptions`, which + * has no `blocks` field at all. + * + * Destructuring (not a spread-then-delete) keeps this `exactOptionalPropertyTypes`-safe: + * `rest` only ever carries a `block` key when the caller's own `options` did, + * and the override branch sets `block` to a value that is never `undefined` + * (guarded by the `!== undefined` check) — an explicit `block: undefined` is + * never assigned here. + */ +function effectiveOptionsFor(chainId: number, options: MultichainRunOptions | undefined): BatchOptions { + if (!options) return {} + const { blocks, ...rest } = options + const chainBlock = blocks?.[chainId] + return chainBlock !== undefined ? { ...rest, block: chainBlock } : rest +} + +/** + * Runs task plans across several chains in parallel — one `StepExecutor` + * (deployed/deployless Multicall3, or a custom `StepExecutor`) per chain id, + * fanning `runAll`/`runAllSettled` out to the existing single-chain runners. + * + * **Single-`T` generic:** `runAll`/`runAllSettled` type every chain's + * tasks as `MultistepTask[]` — mixed shapes across chains require + * separate `chain(id).run(...)` calls (one per distinct result shape) + * instead of a single `runAll` invocation. + * + * **`onPin` and multichain:** each chain's runner resolves and reports its + * OWN pin independently (F8's "once per run" becomes "once per chain" here) + * — `onPin` itself receives no chain id, so a single shared callback cannot + * tell which chain's pin it just saw. Consumers needing that attribution + * should use `snapshot()` (which DOES return a chain-keyed block map) plus + * explicit per-chain `blocks` overrides instead of relying on `onPin`'s + * callback identity to disambiguate. + */ +export class MultichainResolver { + readonly #entries: Map + readonly #executors = new Map() + readonly #resolvers = new Map() + + constructor(chains: Record) { + const keys = Object.keys(chains) + if (keys.length === 0) { + throw new Error('MultichainResolver: at least one chain is required (received an empty chains record)') + } + + this.#entries = new Map() + for (const key of keys) { + const chainId = Number(key) + const entry = chains[chainId]! + // Discrimination order matters: a `StepExecutor` (callable + // `executeMulticall`) is checked first and used as-is; only then is a + // callable `request` treated as an `Eip1193Provider` awaiting lazy + // wrapping. Anything satisfying neither shape is a construction error. + if (isStepExecutor(entry)) { + this.#entries.set(chainId, entry) + } else if (isEip1193Provider(entry)) { + this.#entries.set(chainId, entry) + } else { + throw new Error( + `MultichainResolver: chain ${key} is neither a StepExecutor (callable executeMulticall) ` + + 'nor an Eip1193Provider (callable request)', + ) + } + } + } + + get #chainIds(): number[] { + return [...this.#entries.keys()] + } + + /** + * Resolves (lazily wrapping + caching an `Eip1193Provider` entry into an + * `Eip1193Executor` on first use) the `StepExecutor` for `chainId`. Every + * caller inside this class — `chain()`, `snapshot()`, `runAll`, + * `runAllSettled` — funnels through this ONE cache, so a provider is + * wrapped exactly once no matter which of those is called first, or how + * many times. + */ + #executorFor(chainId: number): StepExecutor { + const cached = this.#executors.get(chainId) + if (cached) return cached + + const entry = this.#entries.get(chainId) + if (!entry) { + const known = this.#chainIds.sort((a, b) => a - b).join(', ') + throw new Error(`MultichainResolver: unknown chainId ${chainId} (known chain ids: ${known})`) + } + + const executor = isStepExecutor(entry) ? entry : new Eip1193Executor(entry) + this.#executors.set(chainId, executor) + return executor + } + + /** + * Throws before ANY chain begins execution if `plan` references a chain id + * this resolver wasn't constructed with. + */ + #assertKnownPlanChainIds(planChainIds: number[]): void { + const known = new Set(this.#chainIds) + for (const chainId of planChainIds) { + if (!known.has(chainId)) { + const knownList = [...known].sort((a, b) => a - b).join(', ') + throw new Error(`MultichainResolver: plan references unknown chainId ${chainId} (known chain ids: ${knownList})`) + } + } + } + + /** + * A cached `MulticallResolver` wrapping `chainId`'s (lazily created) + * executor. Same instance every call — `toBe`-stable — so callers may + * safely hold onto `chain(id)` across a run instead of re-deriving it. + * Unknown `chainId` throws immediately, listing every known id. + */ + chain(chainId: number): MulticallResolver { + const cached = this.#resolvers.get(chainId) + if (cached) return cached + + const executor = this.#executorFor(chainId) // throws for an unknown chainId + const resolver = new MulticallResolver(executor) + this.#resolvers.set(chainId, resolver) + return resolver + } + + /** + * Resolves the current block number of EVERY constructed chain in + * parallel — `Promise.all` over `executor.getBlockNumber()`. + * + * Capability is checked for ALL chains BEFORE any RPC is dispatched: a + * single chain lacking `getBlockNumber` throws synchronously, with zero + * `request` calls made against ANY chain (not just the offending one). + * + * If one chain's `getBlockNumber()` rejects, `snapshot()` rejects with + * that error — but every OTHER in-flight promise already has an explicit + * no-op `.catch` attached (in the same synchronous pass that started them, + * before the `Promise.all` below), so a sibling settling AFTER the + * rejection has already propagated can never surface as a Node + * `unhandledRejection`. + * + * **Why each call is wrapped in `Promise.resolve().then(...)` (external + * review, P1):** `executor.getBlockNumber` is typed as returning a + * `Promise`, but nothing stops a non-conforming custom `StepExecutor` from + * implementing it as a plain (non-`async`) function that throws + * SYNCHRONOUSLY instead. Calling it directly inside the `.map()` below + * would let that throw abort the `.map()` call itself mid-iteration — + * discarding whatever promise(s) EARLIER iterations already created, with + * no handler ever attached to them (a real, reproduced unhandled-rejection + * hazard when an earlier chain's own promise later rejects, not a + * hypothetical). Deferring the actual call into a microtask means `.map()` + * itself can never throw — every iteration unconditionally produces a + * promise (whose eventual rejection, sync-throw or async, is then + * capturable) before any of them has actually run, so the `.catch(noop)` + * loop below always reaches every one. + */ + async snapshot(): Promise> { + const chainIds = this.#chainIds + const executors = chainIds.map((chainId) => this.#executorFor(chainId)) + + for (let i = 0; i < chainIds.length; i++) { + if (typeof executors[i]!.getBlockNumber !== 'function') { + throw new Error( + `MultichainResolver.snapshot: chain ${chainIds[i]} executor does not implement getBlockNumber ` + + '(Eip1193Executor implements it; a custom StepExecutor must add it to opt in)', + ) + } + } + + const pending = executors.map((executor) => Promise.resolve().then(() => executor.getBlockNumber!())) + for (const p of pending) p.catch(() => {}) + + const values = await Promise.all(pending) + + const out: Record = {} + for (let i = 0; i < chainIds.length; i++) out[chainIds[i]!] = values[i]! + return out + } + + /** + * Runs `plan[chainId]` against chain `chainId`'s executor for every chain + * key in `plan`, all concurrently, via the existing `runMultistepTasks`. + * + * Validation (unknown plan chain id, [v5] flattened duplicate instances) + * happens entirely BEFORE any chain's runner is invoked — see + * `#assertKnownPlanChainIds`/`assertNoFlattenedDuplicates` above. + * + * **Failure policy:** if any chain rejects, `runAll` rejects — with the + * rejection of the LOWEST chainId among the chains that rejected + * (deterministic; analogous to the concurrency pool's lowest-index + * selection in `src/core/pool.ts`). Every chain's promise gets both its + * fulfillment and rejection handlers attached synchronously via `.then` + * BEFORE `Promise.all` is awaited, so no chain's settlement — whichever + * order they land in — can ever surface as an unhandled rejection. + * In-flight chains are never cancelled because a sibling rejected: each + * chain's own single-chain fail-fast behavior (if any) still applies + * within it, but there is no CROSS-chain cancellation. + */ + async runAll(plan: Record[]>, options?: MultichainRunOptions): Promise> { + const chainIds = Object.keys(plan).map(Number) + this.#assertKnownPlanChainIds(chainIds) + assertNoFlattenedDuplicates(plan) + + if (chainIds.length === 0) return {} + + type Settlement = { chainId: number } & ( + | { status: 'fulfilled'; value: T[] } + | { status: 'rejected'; error: unknown } + ) + + const settlements: Promise[] = chainIds.map((chainId) => { + const executor = this.#executorFor(chainId) + const tasks = plan[chainId] ?? [] + const effectiveOptions = effectiveOptionsFor(chainId, options) + return runMultistepTasks(executor, tasks, effectiveOptions).then( + (value): Settlement => ({ chainId, status: 'fulfilled', value }), + (error: unknown): Settlement => ({ chainId, status: 'rejected', error }), + ) + }) + + const settled = await Promise.all(settlements) + + const rejected = settled.filter( + (s): s is Extract => s.status === 'rejected', + ) + if (rejected.length > 0) { + rejected.sort((a, b) => a.chainId - b.chainId) + throw rejected[0]!.error + } + + const results: Record = {} + for (const s of settled) { + if (s.status === 'fulfilled') results[s.chainId] = s.value + } + return results + } + + /** + * Per-chain settlement variant of {@link runAll}: never rejects on a + * task/call failure (each chain's own `runSettled` isolates those into its + * `SettledTaskResult[]`) — only a programmer error (e.g. an invalid + * `batchSize`, or a `pinBlock` capability rejection for a chain's + * executor) rejects the whole call, exactly as it would for a single-chain + * `runSettled`. The [v5] flattened-duplicate and unknown-plan-chain-id + * checks still run up front, before any chain starts, same as `runAll`. + */ + async runAllSettled( + plan: Record[]>, + options?: MultichainRunOptions, + ): Promise[]>> { + const chainIds = Object.keys(plan).map(Number) + this.#assertKnownPlanChainIds(chainIds) + assertNoFlattenedDuplicates(plan) + + if (chainIds.length === 0) return {} + + const settlements = chainIds.map(async (chainId) => { + const executor = this.#executorFor(chainId) + const tasks = plan[chainId] ?? [] + const effectiveOptions = effectiveOptionsFor(chainId, options) + const value = await runSettled(executor, tasks, effectiveOptions) + return { chainId, value } + }) + + const settled = await Promise.all(settlements) + + const results: Record[]> = {} + for (const { chainId, value } of settled) results[chainId] = value + return results + } +} diff --git a/src/index.ts b/src/index.ts index 0636314..6734df7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,10 @@ export type { DominoCallErrorKind, DominoCallErrorOptions } from './core/errors' export { Eip1193Executor } from './engine/eip1193' export { MulticallResolver, makeResolver } from './engine/resolver' export type { ResolverEngine } from './engine/resolver' + +// Multichain (F9) +export { MultichainResolver } from './engine/multichain' +export type { MultichainRunOptions } from './engine/multichain' export { MULTICALL3_ADDRESS, MULTICALL3_BYTECODE,