Writing scripts
An evs script is an ordinary TypeScript value. You build one with evscript, giving it a header — { name, args }, plus optional declared errors — and a builder callback that records what the script does. Compiling it produces EVM runtime bytecode plus a literal-typed ABI; executing it is a single eth_call.
Anatomy of a script
Section titled “Anatomy of a script”import { evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const tokenMeta = evscript( // 1. header: the script name + an ordered list of argument types { name: 'tokenMeta', args: [t.address, t.address] }, // 2. builder callback: args arrive as positional params after `s` (s, token, owner) => { const symbol = s.read({ address: token, abi: erc20Abi, functionName: 'symbol' }); const decimals = s.read({ address: token, abi: erc20Abi, functionName: 'decimals' }); const balance = s.read({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [owner], }); // 3. the callback must return s.return(...) — it defines the script's named outputs return s.return({ symbol, decimals, balance }); },);
const compiled = tokenMeta.compile(); // bytecode + literal-typed ABI, ready for viemThree parts matter:
- The header
{ name, args }declares the script’s ABI surface (an optionalerrors: [...]adds custom errors fors.throw).namebecomes the function name you pass to viem’sreadContract;argsis an ordered list oft.*types (a lone type or areadonlylist). It is optional — a zero-arg script omits it. - The builder callback receives
s(aScriptBuilder) followed by one positional handle per declared argument. It runs exactly once, on your machine — never on-chain. - The callback must return
s.return(...); the record you pass defines the script’s outputs.
evscript returns an EvsScript value with five members: name, ir (frozen, JSON-serializable), abi (the literal-typed ABI — it exists before you compile), errors (the declared t.error values, [] when none), and compile(options?), which is sugar for the standalone compile(script, options?). Exact signatures are in the evscript reference.
evscript uses const type parameters, so you never write as const on the header or the return record — inference is literal by default.
The header
Section titled “The header”namemust be a non-empty identifier (/^[A-Za-z_]\w*$/) other than__proto__, like every name evs accepts (args, struct fields, return keys, errors).argsis a singlet.*type or areadonlylist of them (a lone type is sugar for a one-element list). It is optional. Declaration order is binding: it is the type-level order, the runtime encode order, and the ABIinputsorder all at once. Call sites stay positional, viem style —args: [token, owner]. Each arg is auto-namedarg{i}in the ABI (positional labels; viem infersargspositionally).- Valid argument types: any word type,
string,bytes, a struct/tuple (t.struct/t.tuple), or an array of any of those up to four levels deep, dynamic or fixed-size (uint256[],tuple[],tuple[][],uint256[][][],string[][],uint256[2],t.array(Position, 3)). An array nested deeper than four levels throws a recording-timeEvsTypeError(codeUNSUPPORTED_V0). See values and types. - Header validation is immediate: invalid identifiers and unknown type shapes throw
EvsTypeErrorbefore anything records.
Reading arguments: positional params
Section titled “Reading arguments: positional params”Arguments arrive as positional parameters after s — there is no s.args. With the header above, the callback is (s, token, owner) => … where token is Expr<'address'> and owner is Expr<'address'>. A scalar arg is an Expr; a struct/tuple arg is a Tuple handle.
These are not values — they are typed handles standing in for the values that calldata will carry at run time. Values and types explains what you can and cannot do with a handle.
Returning values: s.return
Section titled “Returning values: s.return”- Call it exactly once, unconditionally, and return its result from the callback. Calling it inside
s.if/s.while, twice, or not at all is a recording-time error (EvsScopeError/EvsTypeError). - The record’s keys become the named components of the script’s single tuple output. viem consumers receive a plain object with the same keys —
{ symbol: 'DAI', decimals: 18, balance: 123n }-shaped, fully typed. - The record needs at least one key. An empty record would return
0x, which viem reports as “returned no data”, sos.return({})is rejected (a type error, andABI_SHAPEat recording). A script that only guards (reverts or succeeds) returns a flag such as{ ok: s.lit(t.bool, true) }. - Every value is an
Expr, aTuplehandle, or aMutArrayhandle — all returned directly (a struct flows out as an object, an array as a list). Read a cell with.get();.expr()on aTuple/MutArrayis the equivalent optional form (the bare handle works too — see control flow). s.returnseals the recorder: any builder call after it throwsEvsScopeError(RECORDING_CLOSED).
Two timelines: recording time vs run time
Section titled “Two timelines: recording time vs run time”This is the one mental model the whole library hangs on. The builder callback runs once, at recording time, in your JS process. Each builder call appends a statement to the script’s IR and hands back a typed placeholder (Expr) — s.read performs no call, x.add(1n) adds nothing. The compiled bytecode then replays the recorded statements at run time, inside eth_call on the node.
recording time (your machine) run time (the node, inside eth_call)───────────────────────────── ────────────────────────────────────evscript(header, callback) └─ callback runs ONCE, records IR ──► compiled bytecode executes s.read({...}) → Expr handle STATICCALL token.symbol() → real bytes x.add(1n) → Expr handle checked ADD → real word s.return({...}) ABI-encode outputs → returndata| Runs at recording time (TS) | Runs on-chain (compiled) |
|---|---|
| the builder callback, exactly once | the recorded statements, in recorded order |
JS if/for over host values (unrolled, specialized) |
s.if/s.while/s.for over runtime values |
| literal validation and folding, ABI resolution, selectors | checked arithmetic, sub-calls, decoding |
s.fn body (once, at definition) |
the subroutine, once per recorded call |
| loop-condition thunks, recorded once into the header | the header, once per iteration |
The practical consequence: plain JS control flow is a metaprogramming tool. A JS loop over a host array runs at recording time and unrolls — the compiled script contains one copy of the body per iteration, and no loop:
import { evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const TOKENS = [ '0x6B175474E89094C44Da98b954EedeAC495271d0F', // DAI '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH] as const;
const watchlist = evscript({ name: 'watchlist', args: [t.address] }, (s, owner) => { const out = s.newArray(t.uint256, TOKENS.length); let i = 0n; // host loop over a host array: runs at recording time and unrolls — // the compiled script contains one balanceOf call per token, no loop for (const token of TOKENS) { const bal = s.read({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [owner], }); out.set(i, bal); i += 1n; } return s.return({ balances: out.expr() });});When the data is only known at run time — a runtime address[] argument, a value returned by a call — you need the combinators instead: s.if, s.while, s.for, s.select. See control flow and the token balances example.
Finding where an error came from
Section titled “Finding where an error came from”Builder calls validate eagerly and throw synchronously from inside your evscript callback, so
a recording-time error’s ordinary JavaScript stack trace already points at the offending line
of your script. Compile-time diagnostics and runtime reverts name the construct and the site id
involved instead; see errors and debugging for the full
diagnostics story.
- Values and types — what an
Expris and which literals coerce. - Calls —
s.readands.tryReadin depth. - Execution — running the compiled script with viem.