diff --git a/CHANGELOG.md b/CHANGELOG.md
index 433ee0e..f99f970 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,27 @@
All notable changes to this project will be documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## [1.3.0] — 2026-07-23
+
+### Added
+- `MultichainResolver` — parallel per-chain execution with `chain()` builder, `snapshot()` for atomicity, `runAll`/`runAllSettled` entry points, per-chain `blocks` overrides, and flattened duplicate-instance validation before any chain executes. On failure across multiple chains, the error from the lowest-chainId is thrown (deterministic). Single-T generic over result shape per call (mixed shapes require separate invocations).
+- `MultichainRunOptions` — batch options wrapper with per-chain customization via `blocks` map (e.g. `{ blocks: { 1: { blockNumber }, 8453: { blockNumber } } }`).
+- `examples/refinance.ts` — Aave v3 + Spark + Morpho Blue dynamic-target refinance comparison across Ethereum mainnet and Base (optional via `RPC_URL_8453`); demonstrates `defineTask`, `Presets.throughput`, `MultichainResolver`, and per-chain block pinning. Type-checked in CI against the built dist.
+
+### Changed
+- **Handler implementation** — ERC20 and ERC4626 handlers reimplemented on `defineTask` (internal structure change; public signatures identical, source-compatible).
+ - **Observable deltas** (all aligned with legacy behavior for backward compatibility):
+ - `runSettled` now retains per-call errors in task diagnostics for handler tasks (formerly dropped). Populated via `TaskDiagnostics.optionalFailures` only for `defineTask` output; legacy hand-written tasks return empty `[]`.
+ - Handler calls are now dedup-eligible (under `dedupe: true`) when built via `defineTask`; legacy tasks are unaffected (never merged).
+ - Malformed executor values still resolve to `undefined` (coercion parity with legacy).
+ - **Bundle size** — gzip ceiling now 15→18 KB (multichain feature class added; current ~17.2 KB measured).
+
+### Internal
+- Legacy handler oracle retained under `src/__tests__/fixtures/legacy-handlers/` for one release cycle, then removed.
+
+### Deprecated
+- (No new deprecations; see 1.1.0 and 1.0.x MIGRATION sections for prior changes.)
+
## [1.2.0] — 2026-07-23
### Added
@@ -118,6 +139,7 @@ First public release of `@halaprix/domino`.
- `position.assets` is `bigint | undefined` — correctly represents the case where `balanceOf` succeeds but `convertToAssets` reverts.
- CI now runs real `tsc --noEmit` (typecheck); build step runs before tests so `dist/` exists for bundle-size checks on a clean checkout.
+[1.3.0]: https://github.com/halaprix/domino/releases/tag/v1.3.0
[1.2.0]: https://github.com/halaprix/domino/releases/tag/v1.2.0
[1.1.0]: https://github.com/halaprix/domino/releases/tag/v1.1.0
[1.0.1]: https://github.com/halaprix/domino/releases/tag/v1.0.1
diff --git a/MIGRATION.md b/MIGRATION.md
index 3f38020..c6601bf 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -1,4 +1,55 @@
-# Migration Guide — v0.1.0 → v1.0.0
+# Migration Guide
+
+## v1.2.x → v1.3.0
+
+**Fully additive — no consumer action required.** All 1.2.x code keeps working unchanged.
+
+### What's new
+
+- **`MultichainResolver`** — parallel per-chain task execution with `runAll()`/`runAllSettled()` and optional atomic block snapshots via `snapshot()`. No changes to existing single-chain code.
+- **Handler internals** — ERC20 and ERC4626 handlers reimplemented on `defineTask` (internal change; public signatures identical, behavior backward-compatible).
+ - Observable delta in `runSettled`: handler tasks now populate `TaskDiagnostics.optionalFailures` with per-call errors (formerly omitted).
+ - Handler calls are dedup-eligible under `dedupe: true` (newly, though the feature itself is unchanged).
+ - No consumer code needs updating.
+
+### Example: multichain snapshot
+
+```typescript
+import { createPublicClient, http } from "viem"
+import { mainnet, base } from "viem/chains"
+import { Eip1193Executor, MultichainResolver, defineTask } from "@halaprix/domino"
+import type { Address } from "@halaprix/domino"
+
+declare const vaultAddress: Address
+declare const ownerAddress: Address
+
+const resolver = new MultichainResolver({
+ [mainnet.id]: new Eip1193Executor(createPublicClient({ chain: mainnet, transport: http() })),
+ [base.id]: new Eip1193Executor(createPublicClient({ chain: base, transport: http() })),
+})
+
+// Snapshot block numbers per chain (atomic)
+const blocks = await resolver.snapshot()
+
+// Execute the same task on each chain
+const vaultAbi = [
+ "function balanceOf(address) view returns (uint256)",
+] as const
+
+const makeTasks = () => [
+ defineTask((t) => ({
+ balance: t.call({ target: vaultAddress, abi: vaultAbi, functionName: "balanceOf", args: [ownerAddress] }),
+ })),
+]
+
+// Execute tasks with pinned per-chain blocks
+const results = await resolver.runAll({
+ [mainnet.id]: makeTasks(),
+ [base.id]: makeTasks(),
+}, { blocks: Object.fromEntries(Object.entries(blocks).map(([cid, num]) => [cid, { blockNumber: num }])) })
+```
+
+## v0.1.0 → v1.0.0
## What changed
diff --git a/README.md b/README.md
index ccebb0a..bed77d7 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ _| _| _| _| _| _| _| _| _| _| _| _|
[](https://github.com/halaprix/domino/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/@halaprix/domino)
-[](https://www.npmjs.com/package/@halaprix/domino)
+[](https://www.npmjs.com/package/@halaprix/domino) (v1.3.0)
[](https://www.typescriptlang.org/)
[](LICENSE)
@@ -235,6 +235,52 @@ const oldVault = await resolveErc4626Vault({
Works with `blockHash`, `blockTag`, or `blockNumber`. Even on chains where Multicall3 didn't exist yet — domino falls back to deployless multicall automatically.
+## Multichain queries with `MultichainResolver`
+
+Execute the same task graph across multiple chains in parallel, with atomic block snapshots per chain:
+
+```typescript
+import { createPublicClient, http } from "viem"
+import { mainnet, base } from "viem/chains"
+import { Eip1193Executor, MultichainResolver, defineTask } from "@halaprix/domino"
+import type { Address } from "@halaprix/domino"
+
+const resolver = new MultichainResolver({
+ [mainnet.id]: new Eip1193Executor(createPublicClient({ chain: mainnet, transport: http() })),
+ [base.id]: new Eip1193Executor(createPublicClient({ chain: base, transport: http() })),
+})
+
+// Snapshot block numbers per chain for atomicity
+const blocks = await resolver.snapshot()
+
+declare const vaultAddress: Address
+declare const ownerAddress: Address
+
+// Task factory — create a fresh task per chain (tasks are single-run)
+const makeTask = () =>
+ defineTask((t) => ({
+ balance: t.call({
+ target: vaultAddress,
+ abi: [{ type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }] }],
+ functionName: 'balanceOf',
+ args: [ownerAddress],
+ }),
+ }))
+
+// Run the same task graph on each chain with pinned blocks
+const results = await resolver.runAll({
+ [mainnet.id]: [makeTask()],
+ [base.id]: [makeTask()],
+}, {
+ blocks: Object.fromEntries(
+ Object.entries(blocks).map(([cid, num]) => [cid, { blockNumber: num }])
+ ),
+})
+// results is { [mainnet.id]: [...], [base.id]: [...] }
+```
+
+For a complete example spanning Aave v3, Spark, and Morpho Blue across chains, see [`examples/refinance.ts`](examples/refinance.ts).
+
## When NOT to use it
- Pure batches (no dependencies) → plain `multicall` is simpler.
@@ -247,7 +293,8 @@ Works with `blockHash`, `blockTag`, or `blockNumber`. Even on chains where Multi
|--------|-----------|
| `defineTask()` | **Recommended.** Ref-graph task builder — `t.call`/`t.derive`, compiles to a `MultistepTask` |
| `runSettled()` | Per-task settlement — every task gets its own fulfilled/rejected outcome instead of one failure aborting the whole call |
-| `MulticallResolver` | Convenience layer — call `run()`/`runSettled()` to execute a state machine |
+| `MultichainResolver` | Parallel per-chain execution — `chain()`, `snapshot()`, `runAll()`/`runAllSettled()` with per-chain block pinning and atomicity |
+| `MulticallResolver` | Single-chain convenience layer — call `run()`/`runSettled()` to execute a state machine |
| `Eip1193Executor` | Single engine — works with any EIP-1193 provider |
| `runMultistepTasks()` | Core FSM — bare-metal version of the resolver |
| `DominoCallError` | Structured error for a failed call — `kind` discriminates revert/decode/batch/skipped/derive |
@@ -263,10 +310,10 @@ Works with `blockHash`, `blockTag`, or `blockNumber`. Even on chains where Multi
## Documentation
+- [API Reference](docs/api-reference.md) — full `defineTask`, `runSettled`, `MultichainResolver`, `Eip1193Executor` docs
+- [Benchmarks](docs/benchmarks.md) — bundle size, RPC round-trip counts, `Presets.throughput` tuning
+- [Refinance example](examples/refinance.ts) — Aave v3/Spark/Morpho across chains, dynamic `Ref
` targets, per-chain block pinning
- [Architecture & AI Context](CLAUDE.md)
-- [API Reference](docs/api-reference.md)
-- [Benchmarks](docs/benchmarks.md)
-- [Refinance example](examples/refinance.ts) — Aave v3/Spark/Morpho across chains, dynamic `Ref` targets, `Presets.throughput`, `MultichainResolver`
- [Migration Guide](MIGRATION.md)
- [Changelog](CHANGELOG.md)
diff --git a/SUMMARY.md b/SUMMARY.md
index 5858fbe..ff9d328 100644
--- a/SUMMARY.md
+++ b/SUMMARY.md
@@ -3,7 +3,11 @@
* [Introduction](README.md)
* [Quick Start](README.md#quick-start)
* [API Reference](docs/api-reference.md)
+ * [defineTask](docs/api-reference.md#definetask--the-recommended-way-to-describe-a-task)
+ * [MultichainResolver](docs/api-reference.md#multichainresolver--parallel-per-chain-execution)
+ * [Performance Guidance](docs/api-reference.md#performance-guidance)
* [Benchmarks](docs/benchmarks.md)
* [Architecture](CLAUDE.md)
* [Contributing](CONTRIBUTING.md)
-* [Changelog](CHANGELOG.md)
\ No newline at end of file
+* [Changelog](CHANGELOG.md)
+* [Migration Guide](MIGRATION.md)
\ No newline at end of file
diff --git a/docs/api-reference.md b/docs/api-reference.md
index ed1da50..8b981cf 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -306,7 +306,7 @@ Adaptive bisection (`adaptiveBatching: true`, off by default) automatically spli
| `maxBatchAttempts` | `number` | `2·⌈log₂(batchSize)⌉+1` | Bounds bisection recursion. Default is sized for the common single-bad-call case; batches with multiple failing calls may exhaust it before every one is isolated (producing coarser-grained `kind: 'batch'` failures instead, never wrong data). |
| `dedupe` | `boolean` | `false` | Cross-task call merging changes executor invocation counts seen by instrumentation/billing. Only `TypedCallSpec` calls (from `t.call` in `defineTask`) are ever eligible; legacy tasks are never affected. |
| `block` | `BlockParam` | `{ blockTag: 'latest' }` | Block to query at (EIP-1898). Every step's `executeMulticall` call uses this parameter; without `pinBlock: true`, stable tags like `'latest'` are re-resolved by the node on each separate `eth_call`, so the chain can advance between steps. |
-| `pinBlock` | `boolean` | `false` | Resolve one concrete block at run start (+1 RPC) and reuse for every step, closing the "chain advanced between steps" gap. Requires `executor.getBlockNumber()`. |
+| `pinBlock` | `boolean` | `false` | Resolve one concrete block at run start and reuse for every step (adds +1 RPC round-trip only for absent or stable tags like `'latest'`; explicit `blockNumber`/`blockHash` add none). Closing the "chain advanced between steps" gap. Requires `executor.getBlockNumber()`. |
| `onPin` | `(block: PinnedBlock) => void` | — | Synchronous callback reporting the resolved block (fired exactly once per run when `pinBlock: true`). Only invoked when `pinBlock: true`; supplying it without `pinBlock` is accepted but it is never called. |
See `Presets.throughput` (exports: `{ maxConcurrentBatches: 5, adaptiveBatching: true, dedupe: true }`) for a ready-made bundle suited to portfolio workloads:
@@ -321,6 +321,73 @@ declare const tasks: MultistepTask[]
await runMultistepTasks(executor, tasks, { ...Presets.throughput, batchSize: 200 })
```
+## `MultichainResolver` — parallel per-chain execution
+
+`MultichainResolver` enables executing the same task graph across multiple chains in parallel, with optional atomic block snapshots and per-chain customization. Unlike `MulticallResolver` (single-chain convenience layer), multichain targets a portfolio use case where the same read pipeline (e.g., "fetch USDC balance on mainnet, Base, Arbitrum") needs per-chain results without re-coding for each chain.
+
+### Constructor & provider vs. executor
+
+```typescript
+import { createPublicClient, http } from "viem"
+import { mainnet, base, arbitrum } from "viem/chains"
+import { Eip1193Executor, MultichainResolver } from "@halaprix/domino"
+
+// Provide an executor per chain
+const resolver = new MultichainResolver({
+ [mainnet.id]: new Eip1193Executor(createPublicClient({ chain: mainnet, transport: http() })),
+ [base.id]: new Eip1193Executor(createPublicClient({ chain: base, transport: http() })),
+ [arbitrum.id]: new Eip1193Executor(createPublicClient({ chain: arbitrum, transport: http() })),
+})
+```
+
+### Methods
+
+| Method | Signature | Notes |
+|--------|-----------|-------|
+| `chain(chainId)` | `(chainId: number) => MulticallResolver` | Retrieve a cached `MulticallResolver` wrapping the given chain's executor. Throws with a descriptive error if the chain was not registered in the constructor, listing every known chain id. |
+| `snapshot()` | `(): Promise>` | Atomically resolve one block number per registered chain (calls `executor.getBlockNumber()` for each). Returns a map of `chainId → blockNumber`. Used with explicit `blocks` param to pin the same observed block for every task on that chain. |
+| `runAll(plan, options?)` | `(plan: Record[]>, options?: MultichainRunOptions): Promise>` | Execute tasks per chain in parallel; each chain receives its own effective `block` from the `blocks` map (if present) or the base `block` option. If a plan references a chain id not registered in the constructor, throws before any executor is invoked. On chain failure, rejects with the error from the chain with the lowest id among the failed chains (deterministic). |
+| `runAllSettled(plan, options?)` | `(plan: Record[]>, options?: MultichainRunOptions): Promise[]>>` | Per-task settlement per chain — like `runAll` but each chain's tasks settle independently; one chain's failure never blocks another. Validation (unknown chains, flattened duplicates) still runs before any chain starts. |
+
+### `MultichainRunOptions`
+
+Extends `BatchOptions` with per-chain block overrides:
+
+```typescript
+import type { BatchOptions, BlockParam } from "@halaprix/domino"
+
+// MultichainRunOptions extends BatchOptions and adds:
+type MultichainRunOptionsExtension = {
+ blocks?: Record
+}
+```
+
+- `blocks` — optional map of `chainId → BlockParam`. When present, that chain uses the supplied block param instead of the base `options.block`; chains with no entry here fall back to `options.block` as normal. Note: `onPin` (inherited from `BatchOptions`) is the single-chain callback; each chain reports to it once per run, but it receives no chain id attribution.
+
+### Flattened duplicate-instance validation
+
+Before ANY chain executes, `runAll`/`runAllSettled` validates that single-use tasks (`defineTask`, `buildErc20Task`, `buildErc4626Task` output) are not duplicated across the entire `plan` (not just per-chain). A reused instance is detected and throws `DominoTaskReuseError` before the first executor is invoked. Build fresh tasks for each entry, or share tasks only if they are hand-written `MultistepTask` objects (which are stateless and reusable).
+
+### Chain failure determinism
+
+If multiple chains reject during `runAll`, the rejection from the chain with the LOWEST id among those that failed is thrown (deterministic tie-breaking). This mirrors the concurrency pool's own determinism rule in single-chain `run()`. `runAllSettled` never rejects on task/call failures (those are isolated per chain); it only rejects on programmer errors like unknown plan chain ids.
+
+### Single-T generic
+
+`MultichainResolver` is generic over a single result shape `T`, not a per-chain heterogeneous map. If different chains need different task shapes, call `runAll`/`runAllSettled` separately per shape:
+
+```typescript
+import { mainnet, base } from "viem/chains"
+import type { MultistepTask, MultichainResolver } from "@halaprix/domino"
+
+declare const resolver: MultichainResolver
+declare const mainnetTasks: MultistepTask<{ balance: bigint }>[]
+declare const baseTasks: MultistepTask<{ balance: bigint }>[]
+
+const mainnetResults = await resolver.runAll({ [mainnet.id]: mainnetTasks })
+const baseResults = await resolver.runAll({ [base.id]: baseTasks })
+```
+
## Error taxonomy
`DominoCallError` is the single error type domino constructs for a failed call. `kind` discriminates why the call failed; `data` and `cause` carry different information depending on `kind` — they're separate fields (never conflated) because a decode failure needs both the raw bytes that failed to decode and the decoder exception that explains why:
@@ -428,7 +495,7 @@ Remember: `buildErc20Task`/`buildErc4626Task`/`defineTask` output is single-use
## Framework-Agnostic Handlers
-For testing or custom backends, use the handler functions directly with any `StepExecutor`:
+For testing or custom backends, use the handler functions directly with any `StepExecutor`. (G1 note: in v1.3.0, handlers were reimplemented on `defineTask` for internal consistency; public signatures and behavior remain unchanged. Per-task error diagnostics now populate `TaskDiagnostics.optionalFailures` when used with `runSettled`.)
```typescript
import { resolveErc4626Vault } from "@halaprix/domino"
@@ -484,6 +551,30 @@ interface Erc4626VaultResolution {
`metadata.maxWithdraw`/`metadata.maxRedeem` are kept for backward compatibility and always equal `position.maxWithdraw`/`position.maxRedeem` when both are present — new code should read them off `position` (which additionally omits the keys entirely, rather than resolving to `undefined`, when the underlying call fails — see [Deprecations](#deprecations)).
+## Performance guidance
+
+### When `Presets.throughput` helps
+
+`Presets.throughput` bundles `maxConcurrentBatches: 5`, `adaptiveBatching: true`, and `dedupe: true` — suited for **portfolio/index workloads** where many independent reads are batched together and the same contracts/tokens appear repeatedly. It is **not** recommended for single-entity reads or when the provider's payload/rate limits are strict.
+
+### Batch size tradeoffs
+
+- **Default `batchSize: 100`** — conservative, works on all public RPCs.
+- **Larger (200–500)** — higher per-call throughput if your provider supports it (test with `npm run benchmark:live`); reduces round-trips for large workloads.
+- **Smaller (10–50)** — only if your provider has tight per-request payload limits (rare on modern RPC services).
+
+See [Benchmarks](benchmarks.md) for live timing data and provider recommendations.
+
+### Block pinning and cross-step consistency
+
+`pinBlock: true` resolves one concrete block at run start and reuses it for every step and physical batch. When the `block` option is absent or carries a stable tag (e.g. `'latest'`), this adds +1 RPC round-trip; when an explicit `blockNumber` or `blockHash` is provided, no extra RPC is incurred (the value is used as-is). Pinning guarantees all reads see the same EVM state, even if the chain advances between steps. For single-step tasks or when eventual consistency is acceptable, omit `pinBlock` (default `false`).
+
+`MultichainResolver.snapshot()` similarly pins one block per chain, but returns the block map explicitly so the caller can reuse it across multiple `runAll` calls without re-resolving. Use this when a cross-chain comparison needs the same block observed once, then held stable for follow-up queries.
+
+### Dedup hit-rate
+
+`dedupe: true` merges calls with identical `(target, calldata, output signature)` **within one step, across tasks**, before batching/bisection. The hit rate depends on repetition (how many tasks reference the same contract/function), not batch size. See [Benchmarks § Dedup Hit Rate](benchmarks.md#dedup-hit-rate-f7) for examples and a code sample showing 90% call reduction in a same-token portfolio.
+
## Deprecations
These names/parameters still work, unchanged, through the rest of the 1.x line — no breaking changes are planned before 2.0:
@@ -542,6 +633,14 @@ Import the following directly from `@halaprix/domino` (this list is exhaustive
- `makeResolver(executor: StepExecutor): ResolverEngine` — `@deprecated`, equivalent to `new MulticallResolver(executor)`
- `ResolverEngine` — the interface `MulticallResolver` implements. `TAddr` is the address representation the engine's params accept — `Address` (`0x${string}`) by default, or plain `string` for an engine (e.g. ethers v5) that doesn't use the `0x${string}` template-literal type.
+**`MultichainResolver` class** — parallel per-chain execution (see [MultichainResolver](#multichainresolver--parallel-per-chain-execution) section above):
+- `constructor(chains: Record)`
+- `chain(chainId: number): StepExecutor | undefined`
+- `snapshot(): Promise>`
+- `runAll(plan: Record[]>, options?: MultichainRunOptions): Promise>`
+- `runAllSettled(plan: Record[]>, options?: MultichainRunOptions): Promise[]>>`
+- `MultichainRunOptions` — extends `BatchOptions` with `blocks?: Record` and `onPin?: Record void>`
+
**Constants and deployment helpers:**
- `MULTICALL3_ADDRESS: Address`
- `MULTICALL3_BYTECODE: string`
diff --git a/docs/benchmarks.md b/docs/benchmarks.md
index 49fecdd..82dd534 100644
--- a/docs/benchmarks.md
+++ b/docs/benchmarks.md
@@ -2,7 +2,7 @@
## Bundle Size
-`@halaprix/domino` is a lightweight wrapper. The package size (gzip) is **13.9 KB**. The library requires viem as a hard dependency.
+`@halaprix/domino` is a lightweight wrapper. The package size (gzip) is **16.8 KB** (v1.3.0). The library requires viem as a hard dependency.
```typescript
import { Eip1193Executor, MulticallResolver } from "@halaprix/domino"
@@ -15,7 +15,7 @@ const resolver = new MulticallResolver(executor)
| Package | Size (gzip) | Notes |
|---|---|---|
-| `@halaprix/domino` | 13.9 KB | viem is a hard dependency; installed with the package, not bundled into dist |
+| `@halaprix/domino` | 16.8 KB (v1.3.0) | viem is a hard dependency; installed with the package, not bundled into dist; v1.2.0 was 13.9 KB |
## Compared to Alternatives
diff --git a/docs/index.html b/docs/index.html
index 05577da..21b79f1 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -1,569 +1,163 @@
-
-
-
-
-domino — sequential on-chain reads
-
-
-
-
-
-
-
-
-
-
-
-
-
-bolt Batched on-chain reads
-
-
- Batched on-chain reads.
-Without the compromise.
-
-
- domino solves the N×M RPC problem. Sequential, self-triggering on-chain reads — resolved in M multicalls, not N×M individual calls.
-