Skip to content

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.

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 viem

Three parts matter:

  • The header { name, args } declares the script’s ABI surface. name becomes the function name you pass to viem’s readContract; args is an ordered list of t.* types (a lone type or a readonly list). It is optional — a zero-arg script omits it.
  • The builder callback receives s (a ScriptBuilder) 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.

  • name must be a non-empty identifier (/^[A-Za-z_]\w*$/).
  • args is a single t.* type or a readonly list 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 ABI inputs order all at once. Call sites stay positional, viem style — args: [token, owner]. Each arg is auto-named arg{i} in the ABI (positional labels; viem infers args positionally).
  • Valid argument types: any word type, string, bytes, an array (including tuple[], one-level T[][], and string[]/bytes[]), or a struct/tuple (t.struct/t.tuple). Still-unsupported shapes — two-level tuple arrays (tuple[][]), arrays nested deeper than [][], and fixed T[N] — throw a recording-time EvsTypeError (code UNSUPPORTED_V0). See values and types.
  • Header validation is immediate: invalid identifiers and unknown type shapes throw EvsTypeError before anything records.

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.

  • 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, a Tuple handle, or a MutArray handle — all returned directly (a struct flows out as an object, an array as a list). Read a cell with .get(); .expr() on a Tuple/MutArray is the equivalent optional form (the bare handle works too — see control flow).
  • s.return seals the recorder: any builder call after it throws EvsScopeError (RECORDING_CLOSED).

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.

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, and disassemble().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 Expr is and which literals coerce.
  • Callss.read and s.tryRead in depth.
  • Execution — running the compiled script with viem.