┌─────────┬─────────┐
│ ● ● │ ● │
│ │ ● │
│ ● ● │ ● │
└─────────┴─────────┘
turns M calls × N steps into M multicalls
_| _|
_|_|_| _|_| _|_|_| _|_| _|_|_| _|_|
_| _| _| _| _| _| _| _| _| _| _| _|
_| _| _| _| _| _| _| _| _| _| _| _|
_|_|_| _|_| _| _| _| _| _| _| _|_|
A state machine for on-chain reads. Define steps, push results through. One multicall per step.
npm install @halaprix/dominoRequires viem as a runtime dependency (installed automatically). Works with any EIP-1193 provider — a viem PublicClient, window.ethereum, or any object exposing request({ method, params }). (ethers users need an EIP-1193 adapter.)
Multicall is great for batched reads. But what about when step 2 needs step 1's result?
defineTask lets you describe that dependency directly: call something, get back a Ref to its result, feed the Ref straight into the next call's args (or even its target). domino works out how many steps that graph needs, executes one multicall per step batch (a step with more calls than batchSize splits into several sequential batches), and resolves every Ref before handing you back a plain typed object.
import { createPublicClient, http } from "viem"
import { mainnet } from "viem/chains"
import { Eip1193Executor, defineTask, runMultistepTasks } from "@halaprix/domino"
import type { Address } from "@halaprix/domino"
const provider = createPublicClient({ chain: mainnet, transport: http() })
const executor = new Eip1193Executor(provider)
const vaultAddress = "0x1234567890123456789012345678901234567890" as Address
const ownerAddress = "0x0987654321098765432109876543210987654321" as Address
// Human-readable ABI — the friendliest way to describe a call.
const vaultAbi = [
"function balanceOf(address) view returns (uint256)",
"function convertToAssets(uint256) view returns (uint256)",
] as const
// A factory: call this fresh for each vault/owner pair — domino tasks are
// single-run (see "Single-use tasks" below), so `vaultPosition(vault, owner)`
// returning a new `defineTask(...)` each time is the idiom, not a shared const.
function vaultPosition(vault: Address, owner: Address) {
return defineTask((t) => {
// Step 1: read the owner's share balance.
const balance = t.call({
target: vault,
abi: vaultAbi,
functionName: "balanceOf",
args: [owner],
})
// Step 2: `balance` (a Ref<bigint>) feeds straight into convertToAssets's
// args — domino waits for step 1, then builds this call from the result.
const assets = t.call({
target: vault,
abi: vaultAbi,
functionName: "convertToAssets",
args: [balance],
})
// t.derive computes a value from already-resolved Refs — no extra RPC call.
const hasBalance = t.derive([balance], (bal) => bal > 0n)
return { balance, assets, hasBalance }
})
}
const [result] = await runMultistepTasks(executor, [vaultPosition(vaultAddress, ownerAddress)])
// result.balance: balanceOf output from step 1
// result.assets: convertToAssets(balance) output from step 2
// result.hasBalance: derived locally from balance, no extra RPC callSame task runs via a resolver too, if you're already holding one: await resolver.run([vaultPosition(vaultAddress, ownerAddress)]). For per-task failure isolation instead of one bad task aborting the whole call, swap in runSettled/resolver.runSettled — see the API Reference.
Every task domino builds (defineTask, buildErc20Task, buildErc4626Task) is single-run: submit it to runMultistepTasks/runSettled once, and a second submission of that same instance throws DominoTaskReuseError. That's why the idiom above is a factory function (vaultPosition(vault, owner)) rather than a module-level constant — call the factory again to get a fresh instance for the next run.
defineTask output is a MultistepTask — the same buildStepCalls/consumeStepResults/finalize shape you'd write by hand. That hand-written form is still there as an escape hatch, for full control over step gating and result routing that the ref-graph builder can't express (yet):
import { createPublicClient, http } from "viem"
import { mainnet } from "viem/chains"
import { Eip1193Executor, runMultistepTasks } from "@halaprix/domino"
import type { StepCall, StepResult, MultistepTask, Address } from "@halaprix/domino"
const provider = createPublicClient({ chain: mainnet, transport: http() })
const executor = new Eip1193Executor(provider)
// ERC4626 ABI fragments
const erc20Abi = [
{ type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ name: 'account', type: 'address' }], outputs: [{ type: 'uint256' }] },
] as const
const erc4626Abi = [
{ type: 'function', name: 'convertToAssets', stateMutability: 'view', inputs: [{ name: 'shares', type: 'uint256' }], outputs: [{ type: 'uint256' }] },
] as const
// Step 1: read vault balance; Step 2: convert balance to assets
const vaultAddress = "0x1234567890123456789012345678901234567890" as Address
const ownerAddress = "0x0987654321098765432109876543210987654321" as Address
type VaultResult = { balance: bigint | undefined; assets: bigint | undefined }
// Closure context: step 2 depends on step 1's results
const ctx: { balance?: bigint; assets?: bigint } = {}
const task: MultistepTask<VaultResult> = {
maxStep: 2,
buildStepCalls(step) {
if (step === 1) {
return [
{ key: "balance", target: vaultAddress, abi: erc20Abi, functionName: "balanceOf", args: [ownerAddress] },
]
}
if (step === 2) {
// Skip step 2 if we didn't get a balance from step 1
if (ctx.balance === undefined) return []
return [
{ key: "assets", target: vaultAddress, abi: erc4626Abi, functionName: "convertToAssets", args: [ctx.balance] },
]
}
return []
},
consumeStepResults(step, results: StepResult[]) {
// Route results by step: store them in ctx for next step
for (const r of results) {
if (r.status === 'success') {
if (step === 1 && r.key === 'balance') {
ctx.balance = r.value as bigint
}
if (step === 2 && r.key === 'assets') {
ctx.assets = r.value as bigint
}
}
}
},
finalize() {
return { balance: ctx.balance, assets: ctx.assets }
},
}
const [result] = await runMultistepTasks(executor, [task])
// result.balance: balanceOf output from step 1
// result.assets: convertToAssets(balance) output from step 2Unlike a defineTask output, a hand-written MultistepTask like this one is not single-use by default — it's plain data, reusable if you keep it stateless. Read the source of erc4626.ts for a production example written this way.
For convenience, domino ships with pre-built task builders:
import { createPublicClient, http } from "viem"
import { mainnet } from "viem/chains"
import { Eip1193Executor, resolveErc4626Vault, resolveErc4626Bulk } from "@halaprix/domino"
import type { Address } from "@halaprix/domino"
const provider = createPublicClient({ chain: mainnet, transport: http() })
const executor = new Eip1193Executor(provider)
const vaultAddress = "0x1234567890123456789012345678901234567890" as Address
const ownerAddress = "0x0987654321098765432109876543210987654321" as Address
// One vault — 2 steps (metadata + convertToAssets)
const vault = await resolveErc4626Vault({
executor,
vault: vaultAddress,
owner: ownerAddress,
})
// Returns: { metadata: { symbol, decimals, underlyingAsset, ... }, position: { balance, assets } }
// 100 vaults + owner at default batchSize 100:
// Step 1: 6 calls/vault = 600 calls = 6 batches
// Step 2: 1 call/vault = 100 calls = 1 batch
// Total: 7 round-trips
const vaultAddrs = Array(100).fill("0x1234567890123456789012345678901234567890") as Address[]
const vaults = await resolveErc4626Bulk({
executor,
entries: vaultAddrs.map(v => ({ vault: v, owner: ownerAddress })),
})Same pattern for ERC20 (resolveErc20Token / resolveErc20Bulk), and you can buildErc4626Task() / buildErc20Task() to compose them into custom pipelines — both are single-run factories too, same contract as defineTask.
resolveErc4626Bulk/resolveErc20Bulk are the canonical names (F11). The original resolveErc4626VaultsBulk/resolveErc20TokensBulk names still work — they're @deprecated aliases pointing at the same function (resolveErc4626VaultsBulk === resolveErc4626Bulk), kept for the rest of the 1.x line.
Query any block with EIP-1898:
import { createPublicClient, http } from "viem"
import { mainnet } from "viem/chains"
import { Eip1193Executor, resolveErc4626Vault } from "@halaprix/domino"
import type { Address } from "@halaprix/domino"
const provider = createPublicClient({ chain: mainnet, transport: http() })
const executor = new Eip1193Executor(provider)
const vaultAddress = "0x1234567890123456789012345678901234567890" as Address
const ownerAddress = "0x0987654321098765432109876543210987654321" as Address
const oldVault = await resolveErc4626Vault({
executor,
vault: vaultAddress,
owner: ownerAddress,
block: { blockNumber: 19_000_000n },
})Works with blockHash, blockTag, or blockNumber. Even on chains where Multicall3 didn't exist yet — domino falls back to deployless multicall automatically.
Execute the same task graph across multiple chains in parallel, with atomic block snapshots per chain:
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.
- Pure batches (no dependencies) → plain
multicallis simpler. - Write transactions → wrong tool. This reads only.
- Single reads → just use
client.readContract()directly.
| Export | What it is |
|---|---|
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 |
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 |
DominoTaskReuseError |
Thrown when a single-run task (defineTask/buildErc20Task/buildErc4626Task output) is submitted twice |
buildErc20Task() |
Build a task definition for ERC20 token reads |
buildErc4626Task() |
Build a task definition for ERC4626 vault reads |
resolveErc20Token() |
One-shot ERC20: { symbol, decimals, balance } |
resolveErc20Bulk() |
Bulk ERC20 (canonical name — resolveErc20TokensBulk is a deprecated alias) |
resolveErc4626Vault() |
One-shot ERC4626: { metadata: { symbol, decimals, ... }, position: { balance, assets } | undefined } |
resolveErc4626Bulk() |
Bulk ERC4626 (canonical name — resolveErc4626VaultsBulk is a deprecated alias) |
executor: |
Preferred param name on the four standalone resolve* functions (resolveErc20Token, resolveErc20Bulk, resolveErc4626Vault, resolveErc4626Bulk) — client: still works there but is deprecated. MulticallResolver methods don't take it per-call; the executor is passed once, to the constructor. |
BlockParam |
{ blockNumber } | { blockTag } | { blockHash } (one of three) |
- API Reference — full
defineTask,runSettled,MultichainResolver,Eip1193Executordocs - Benchmarks — bundle size, RPC round-trip counts,
Presets.throughputtuning - Refinance example — Aave v3/Spark/Morpho across chains, dynamic
Ref<Address>targets, per-chain block pinning - Architecture & AI Context
- Migration Guide
- Changelog
See our Contributing Guide.