Skip to content

Why evs?

You want a Uniswap V3 pool’s token addresses and each token’s metadata. The reads depend on each other: you cannot ask for symbol() until you know which token to ask.

trip 1 pool.token0() → 0xA0b8…eB48
trip 2 token0.symbol() → "USDC" (needs trip 1's result)
trip 3 token0.decimals() → 6 (needs trip 1's result)

Three sequential round trips — a waterfall. Batching helps only within a dependency level: trips 2 and 3 can share a request, but nothing removes the wait between trip 1 and trip 2, because the second request’s calldata cannot be built until the first response arrives.

Multicall3 does not change this. It packs many calls into one eth_call, but every call in the batch is encoded client-side before the request is sent — the output of one call can never become the input of another. Dependent reads still cost one full round trip per dependency level, plus client-side glue between the levels.

evs moves the data flow on-chain. You write a TypeScript callback against a small builder API; evs compiles it to EVM bytecode and runs the whole thing through a single eth_call:

import { arg, evscript, t } from '@maxencerb/evs';
import { erc20Abi } from 'viem';
const uniswapV3PoolAbi = [
{
type: 'function',
name: 'token0',
stateMutability: 'view',
inputs: [],
outputs: [{ name: '', type: 'address' }],
},
] as const;
export const poolToken0 = evscript(
{ name: 'poolToken0', args: [arg('pool', t.address)] },
(s) => {
const token0 = s.call({ address: s.args.pool, abi: uniswapV3PoolAbi, functionName: 'token0' });
// token0 is Expr<'address'> — it feeds the next two calls ON-CHAIN
const symbol = s.call({ address: token0, abi: erc20Abi, functionName: 'symbol' });
const decimals = s.call({ address: token0, abi: erc20Abi, functionName: 'decimals' });
return s.return({ token0, symbol, decimals });
},
);

token0 is not an address yet — it is a typed handle (Expr<'address'>) for a value that will only exist when the script runs on the node. The compiled bytecode performs all three calls inside one eth_call, threading values between them, and returns one ABI-encoded result. The script is its own literal-typed ABI, so viem infers the argument tuple and the result shape end-to-end — no as const on the script, no codegen step. The quick start shows how to compile and execute one.

Because a script is real bytecode, it is not limited to straight-line call chains: it has checked arithmetic, runtime conditionals, and loops over runtime arrays — see control flow and the token balances example.

Sequential eth_callsMulticall3evs
Round trips for dependent readsone per readone per dependency levelone
Output of one call feeds anotherin your app, between tripsnot expressibleon-chain, via Expr values
Runtime loops and branchesin your app, between tripsnos.if, s.for, s.while
Per-call failure handlingtry/catch per requestallowFailure per calls.tryCall with typed { success, value }
Requires a deployed contractnoyes (per-chain deployment)no
Typed resultsper call, via viemvia viem multicallend-to-end; the script is its own ABI

Both execution modes — deployless and state override — are plain eth_calls, so historical reads via block pinning work and gas is bounded only by the node’s call cap; details in execution.

Next: install the package, then run the quick start.