@maxencerb/evs
v0.4.1
Published
Typed EVM read scripts in plain TypeScript: a callback builder compiled to runtime bytecode, executed via eth_call (deployless or state-override) with full viem inference
Readme
@maxencerb/evs
Typed EVM read scripts in plain TypeScript. You write a callback against a small builder
API; evs compiles it to EVM runtime bytecode and executes it through a single eth_call —
deployless by default, no contract to deploy, with the result fully typed end-to-end through
viem's inference.
Why: a multicall can batch reads, but it cannot feed one call's result into the next —
pool.token0() → token0.symbol() is two round trips, plus client-side decode/re-encode glue,
plus hand-written result types. An evs script moves that data flow on-chain: cross-call values,
loops over runtime arrays, per-call error recovery, and checked arithmetic all run inside one
RPC round trip, and readContract infers the whole result object from the script's generated
literal ABI.
TS callback ──record──▶ IR ──compile──▶ runtime bytecode ──eth_call──▶ typed objectHighlights
- One round trip. Dependent reads — pool → tokens → metadata — collapse into a single
eth_call. No multicall contract, no waterfall of requests, no client-side glue. - Typed end to end. Calls are typed like viem's
readContract; the script is its own literal ABI, so viem infers your arguments and the exact shape of the result. - Nothing to deploy. Runs deploylessly through the
eth_callcodeparameter, or through a state override — any standard RPC node, any block, including historical ones (compiled for the fork active at that block). - Three calling verbs.
s.readfor views,s.callforCALL-frame functions such as a Uniswap quoter,s.simulateto dry-run a write and read back its return value. - Solidity-grade semantics. Checked arithmetic with solc 0.8 panic codes, verbatim revert
bubbling, loops over runtime arrays, per-call error recovery with
try*variants. - Verified bytecode. Every compile passes a
JUMPDESTscan, a stack-height simulation and fork lints; the test suite pins the bytecode against a reference interpreter, viem's codecs and real solc output. - Debuggable. Build-time errors throw from inside your callback, so the stack trace points
at your line;
disassemble()annotates the bytecode with labels and notes, andexplainRevert()maps a revert payload back to the script: the exact site of a decode failure, the candidate sites that can raise a panic, or the call sites a callee revert bubbled through.
Install
bun add @maxencerb/evs viemESM-only. TypeScript ≥ 5.5 in strict mode; peer dependency viem >= 2.14.1 (no other
dependencies: the ABI types come from viem's own abitype); Node ≥ 20.19 (or Bun).
Quickstart — pool metadata in one round trip
token0/token1/slot0 from a Uniswap V3 pool, each token's symbol, a defaulted
decimals, and the user's balance: seven dependent reads, one eth_call. (This mirrors the
runnable examples/pool-meta script, pointed at the real mainnet pool
ABI.)
// abis.ts — plain `as const` ABI fragments (viem-style)
export const uniswapV3PoolAbi = [
{
type: 'function',
name: 'token0',
stateMutability: 'view',
inputs: [],
outputs: [{ type: 'address' }],
},
{
type: 'function',
name: 'token1',
stateMutability: 'view',
inputs: [],
outputs: [{ type: 'address' }],
},
{
type: 'function',
name: 'slot0',
stateMutability: 'view',
inputs: [],
outputs: [
{ name: 'sqrtPriceX96', type: 'uint160' },
{ name: 'tick', type: 'int24' },
{ name: 'observationIndex', type: 'uint16' },
{ name: 'observationCardinality', type: 'uint16' },
{ name: 'observationCardinalityNext', type: 'uint16' },
{ name: 'feeProtocol', type: 'uint8' },
{ name: 'unlocked', type: 'bool' },
],
},
] as const;import { evscript, t } from '@maxencerb/evs';
import { createPublicClient, erc20Abi, http } from 'viem';
import { mainnet } from 'viem/chains';
import { uniswapV3PoolAbi } from './abis';
const poolMeta = evscript(
{ name: 'poolMeta', args: [t.address, t.address] },
// args arrive as positional params after `s`, in declaration order
(s, pool, user) => {
// values flow BETWEEN calls on-chain — a multicall cannot do this
const token0 = s.read({ address: pool, abi: uniswapV3PoolAbi, functionName: 'token0' });
// ^? Expr<'address'>
const token1 = s.read({ address: pool, abi: uniswapV3PoolAbi, functionName: 'token1' });
const slot0 = s.read({ address: pool, abi: uniswapV3PoolAbi, functionName: 'slot0' });
// ^? readonly [Expr<'uint160'>, Expr<'int24'>, …]
const symbol0 = s.read({ address: token0, abi: erc20Abi, functionName: 'symbol' });
const symbol1 = s.read({ address: token1, abi: erc20Abi, functionName: 'symbol' });
const dec = s.tryRead({ address: token0, abi: erc20Abi, functionName: 'decimals' });
const decimals0 = s.select(dec.success, dec.value, 18); // default when the call fails
const bal0 = s.read({
address: token0,
abi: erc20Abi,
functionName: 'balanceOf',
args: [user],
});
return s.return({ token0, token1, symbol0, symbol1, tick: slot0[1], decimals0, bal0 });
},
);
const compiled = poolMeta.compile();
const client = createPublicClient({ chain: mainnet, transport: http() });
const out = await client.readContract({
...compiled.toViem(), // { abi, code } — deployless eth_call, works on any standard RPC
functionName: 'poolMeta',
args: [
'0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640',
'0x0000000000000000000000000000000000000001',
],
// ^ args is typed readonly [pool: `0x${string}`, user: `0x${string}`]
});
// out: {
// token0: `0x${string}`; token1: `0x${string}`; symbol0: string; symbol1: string;
// tick: number; decimals0: number; bal0: bigint
// }No as const needed on the script itself, no codegen step, no ABI files to maintain — the
script is its own literal-typed ABI (poolMeta.abi exists before compiling).
Two execution modes
// 1. Deployless (the default): `{ abi, code }` — a plain 2-parameter eth_call with `to`
// omitted. Maximal portability: works on every provider that implements standard eth_call.
// It runs as a contract creation: result ≤ 24,576 B (no leading 0xEF byte), init bytecode
// + encoded args ≤ ~48.6 KB, and extra gas. State override has none of these limits.
await client.readContract({ ...compiled.toViem(), functionName: 'poolMeta', args });
// 2. State override: `{ abi, address, stateOverride }` — eth_call's third parameter.
await client.readContract({
...compiled.toViem({ mode: 'stateOverride' }),
functionName: 'poolMeta',
args,
account: caller, // msg.sender as seen by the script — controllable in this mode
blockNumber: 22_000_000n, // historical reads work in both modes — it is just eth_call
});
// 2b. Sender mode: the script is installed AT `sender` (+ `account: sender`), so every sub-call
// target — including a simulated write — sees msg.sender = sender.
await client.readContract({
...compiled.toViem({ mode: 'stateOverride', sender: caller }),
functionName: 'poolMeta',
args,
});Prefer stateOverride when you want a stable, human-meaningful address(this) (default
0xcD360FfAC9818c4396Aa6F4807EBfA72C4B3f530, overridable), a controllable msg.sender, or to
compose with further overrides (balance spoofing etc.), or to lift the deployless size limits
(details). It is supported by geth, anvil,
QuickNode and publicnode, but not documented for eth_call on Alchemy or Infura — which is
why deployless is the default (provider matrix). The artifact deliberately exposes runtimeBytecode / initBytecode and never a field
named code: passing runtime bytecode as viem's code fails silently, and toViem() always
hands viem the right flavor.
[!WARNING]
s.env('caller')/s.env('address')are execution-frame-dependent — and the default deployless mode gives you values you cannot control. Deploylesseth_callruns the script inside viem's wrapper:s.env('caller')is the wrapper contract (0xBd770416a3345F91E4B34576cb804a576fa48EB1when noaccountis passed — never your account), ands.env('address')is a per-script counterfactual CREATE2 address. A caller-relative read likebalanceOf(s.env('caller'))therefore silently returns the wrapper's (zero) balance. Caller-relative reads requiretoViem({ mode: 'stateOverride' })plus theaccountcall parameter — there is no deployless workaround.compile()emits anENV_FRAME_DEPENDENTwarning (viaonDiagnostic) whenever a script uses these two env ops;timestamp/blocknumber/chainidare block context and identical in both modes. To model a non-default frame in tests, passinterpret(ir, args, chain, { env: { caller, address } }).
Key concepts
Build time vs run time
The builder callback runs once, at build time, recording statements. Everything that
touches chain data is an Expr<type> handle — a typed placeholder for a value that will only
exist inside eth_call.
[!WARNING] Native JS
if/for/&&does NOT branch on EVM values.if (someExpr)compiles — and the condition is just an object, so it is always truthy; the branch is recorded unconditionally and JS cannot trap it. For runtime values use the combinators:s.if,s.while,s.for,s.select. Use plain JS control flow only over host values (unrolling a known-at-build-time list, for example). Most other misuses (x + 1,`${x}`,JSON.stringify(x),x == 5) throwEvsStagingErrorat the offending line; the truthiness gap is the one JS cannot intercept — turn on thetypescript/strict-boolean-expressionslint to close it.
| Runs at build time (TS) | Runs on-chain (compiled) |
| ------------------------------------------------------ | -------------------------------------------- |
| the builder callback, exactly once | the recorded statements, in recorded order |
| JS if/for over host values (unrolls / specializes) | s.if/s.while/s.for over runtime values |
| literal validation & folding, ABI resolution | checked arithmetic, calls, decoding |
Values and cells
Every operation returns an immutable Expr snapshot — reusing a handle re-reads a value, never
re-executes the computation. The only mutable state is a cell from s.let, and reads are an
explicit .get() so "snapshot vs current value" is visible at every use:
const total = s.let(t.uint256, 0n); // Cell<'uint256'>
total.set(total.get().add(amount)); // `amount` is a positional arg; checked add — Panic 0x11 on overflow
const snapshot = total.get(); // fixed at this program pointControl flow combinators
s.if(
cond,
() => {
/* then */
},
() => {
/* else */
},
); // cond evaluated once, before branching
s.while(
() => i.get().lt(n),
(loop) => {
/* loop.break() / loop.continue() */
},
);
s.for({ type: t.uint256, from: 0n, until: n }, (i, loop) => {
/* i: Expr<'uint256'> */
});
const v = s.select(cond, a, b); // ternary — but EAGER on both sides (they are values already)while conditions are thunks (recorded into a loop header that re-executes per iteration);
s.select does not short-circuit — use s.if + a cell for conditional execution. Loop over a
runtime array with s.for + s.newArray to collect outputs — that is the multicall
replacement pattern (examples/token-balances).
Calls: read, call, simulate
The calling surface is split into three verbs by mutability and call frame. All three share the
same { address, abi, functionName, args } shape (typed like viem's readContract: per-arg
literal-or-Expr unions, outputs unwrapped one→Expr/many→tuple) and a try* variant returning
{ success: Expr<'bool'>, value } (success false on failure or malformed returndata,
value then zeros/empty — pair with s.select for defaults). The whole ABI type vocabulary
flows through args, outputs and returns: words, string/bytes, structs/tuples
(t.struct/t.tuple), and arrays of any element up to four levels deep, dynamic (T[]) or
fixed-size (T[N], t.array(elem, N)) — tuple[], tuple[][], uint256[][][], string[][],
uint256[2][], … — byte-exact vs viem and real solc.
| Verb | Opcode | Functions | State |
| ------------------------------ | ------------------------------------- | ------------------------ | --------------------------------------------------------------- |
| s.read / s.tryRead | STATICCALL | view / pure | static — no writes possible |
| s.call / s.tryCall | CALL | nonpayable / payable | a real frame; the write is not rolled back |
| s.simulate / s.trySimulate | CALL via a self-call + revert macro | nonpayable / payable | the write is rolled back, yet its return value is read back |
s.read is the normal view-read path. s.call opens a non-static frame for functions that
aren't view yet don't usefully persist state — the canonical case is a Uniswap quoter (it
simulates a swap and so can't run under STATICCALL); the write it makes is visible to later
subcalls in the same script but the eth_call itself never commits. s.simulate dry-runs a true
write in a self-call sub-frame that reverts, so you read back what it would return while its
state changes are discarded and isolated from later reads. Mutability is filtered per verb — a
nonpayable function under s.read, or a view function under s.call, is a compile error that
steers you to the right verb. s.call / s.tryCall also take revertReturns: [t.uint256] to
decode a QuoterV1-style target's revert data as the result (the list replaces the ABI outputs as
the decode schema; a normal return is then the failure).
A callee revert bubbles verbatim (Error(string), Panic, custom errors alike) under the
strict verbs, so viem decodes the original error through your script; malformed returndata reverts
a named EvsDecodeError(site). ⚠ s.call/s.simulate make a real call where the target sees
msg.sender as your script's address — use toViem({ mode: 'stateOverride', sender }) (the
script runs at sender) for msg.sender-sensitive targets. Every verb takes an optional gas
cap (for s.simulate it bounds the inner target call), and simulate sites nest freely — inside
s.fn bodies, one dry-run feeding the next. s.call / s.simulate send ETH to a payable
function with value, paid from the script's balance (fund it with a state-override balance).
Checked arithmetic
Arithmetic is checked by default with solc ≥ 0.8 semantics: standard Panic(code) reverts —
0x11 overflow, 0x12 division by zero, 0x32 array out-of-bounds, 0x41 over-allocation.
Narrowing conversions (x.toUint('uint8')) are range-checked; widening is free. Operations
whose literal operands make a panic certain are caught while recording, at the exact line.
Wrapping math (Solidity's unchecked) is an explicit opt-in per operation (a.wrappingSub(b)),
and a.mulDiv(b, d) computes a · b / d over a 512-bit intermediate (FullMath).
Compiling and the artifact
const compiled = poolMeta.compile({ evmVersion: 'paris' }); // 'cancun' (default) | 'shanghai' | 'paris'
compiled.runtimeBytecode; // what runs (state-override mode); EIP-170 size enforced
compiled.initBytecode; // wrapped for deployless mode — what toViem() passes as `code`
compiled.abi; // literal-typed: the script fn + EvsInvalidCalldata + EvsDecodeError
console.log(compiled.disassemble().format()); // annotated listing: labels, jump targets, notes
compiled.explainRevert(revertData).message; // decode site, panic candidates, bubbling call sitesevmVersion lowers PUSH0/MCOPY usage for pre-Shanghai/pre-Cancun chains. The compiler verifies
its own output (JUMPDEST scan, stack-height simulation, opcode/fork lints) before handing it to
you.
Runnable examples
Both examples spawn a throwaway local anvil and deploy mock contracts
generated from the repo's Foundry package, so they need foundry
(forge + anvil), Node ≥ 22.18 (the scripts are TypeScript run directly by Node) with pnpm,
and a clone with submodules (git clone --recurse-submodules, or git submodule update --init):
pnpm install && pnpm run build # once, from the repo root
(cd packages/contracts && pnpm run codegen) # forge build → packages/evs/test/generated/
node examples/pool-meta/index.ts # the quickstart script, end to end
node examples/token-balances/index.ts # loop + tryRead over address[] — the multicall replacementDocumentation and contributing
The full guides, reference and concept pages are at https://evs.maxencerb.com. Repository layout, toolchain, test tiers, design notes and the release flow are in CONTRIBUTING.md.
License
MIT © Maxence Raballand
