Writing scripts
An evs script is an ordinary TypeScript value. You build one with evscript, giving it a header — { name, args } — 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.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 four members: name, ir (frozen, JSON-serializable), abi (the literal-typed ABI — it exists before you compile), 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*$/).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, an array (includingtuple[], one-levelT[][], andstring[]/bytes[]), or a struct/tuple (t.struct/t.tuple). Still-unsupported shapes — two-level tuple arrays (tuple[][]), arrays nested deeper than[][], and fixedT[N]— throw 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. - 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.
Source locations: the locations option
Section titled “Source locations: the locations option”evscript takes an optional third argument, { locations?: boolean } (default true). When enabled, every recorded statement captures the file, line, and column of the builder call that produced it. That is what makes the diagnostics good:
- recording-time errors point at the offending line in your script;
- the compiled
sourceMap,explainRevert, anddisassemble().format()map bytecode program counters back to your source lines.
Pass { locations: false } to skip capture — statements then carry no locations, and diagnostics lose their file:line attribution:
import { evscript, t } from '@maxencerb/evs';
const lean = evscript( { name: 'lean', args: [t.uint256] }, (s, x) => s.return({ doubled: x.mul(2n) }), { locations: false }, // recorded statements carry no source locations);compile() has its own locations option with the same default; 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.