Why evs?
The dependent-read problem
Section titled “The dependent-read problem”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…eB48trip 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.
One script, one eth_call
Section titled “One script, one eth_call”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.
Compared to the alternatives
Section titled “Compared to the alternatives”Sequential eth_calls | Multicall3 | evs | |
|---|---|---|---|
| Round trips for dependent reads | one per read | one per dependency level | one |
| Output of one call feeds another | in your app, between trips | not expressible | on-chain, via Expr values |
| Runtime loops and branches | in your app, between trips | no | s.if, s.for, s.while |
| Per-call failure handling | try/catch per request | allowFailure per call | s.tryCall with typed { success, value } |
| Requires a deployed contract | no | yes (per-chain deployment) | no |
| Typed results | per call, via viem | via viem multicall | end-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.