ScriptBuilder
ScriptBuilder is the s handed to your evscript callback — you never construct one. Every
member records statements into the script’s IR at recording time and validates eagerly: type
mismatches, out-of-range literals, scope violations, and staging misuse all throw an EvsError
subclass at the offending line (see diagnostics). After s.return
the recorder seals; any later builder call throws EvsScopeError (RECORDING_CLOSED).
Member signatures below are interface excerpts, not runnable modules. Expr, IntoExpr,
LitOf, and the type vocabulary are documented in the types reference.
s.args
Section titled “s.args”readonly args: { readonly [a in args[number] as a['name']]: Expr<a['type']> };A record derived from the script’s args tuple: one property per declared argument, typed
Expr of the declared type. Property access records nothing — the argument values are decoded
and validated once at script entry.
import { arg, evscript, t } from '@maxencerb/evs';
const sum = evscript( { name: 'sum', args: [arg('a', t.uint256), arg('b', t.uint256)] }, (s) => s.return({ total: s.args.a.add(s.args.b) }),);lit<const t extends EvsType>(type: t, value: LitOf<t>): Expr<t>;The explicit literal constructor, for when coercion via IntoExpr cannot infer the type you
want. Literals are validated at recording time against the coercion rules.
Word literals canonicalize into the bytecode as constants; dynamic literals (string, bytes,
literal arrays) become bytecode data segments materialized by CODECOPY on first use.
import { evscript, t } from '@maxencerb/evs';
const consts = evscript({ name: 'consts', args: [] }, (s) => { const fees = s.lit(t.array(t.uint24), [100n, 500n, 3000n, 10000n]); // data segment const label = s.lit(t.string, 'hello'); // data segment const max = s.lit(t.uint128, 2n ** 128n - 1n); // canonical word return s.return({ first: fees.at(0n), labelLen: label.length(), max });});let<const t extends EvsType>(type: t, init: IntoExpr<t>): Cell<t>;let<t extends EvsType>(init: Expr<t>): Cell<t>;Declares a mutable cell — the only mutable binding in a script, and the way values escape
s.if branches and loop bodies. The one-argument overload requires an Expr (the cell type is
inferred); to seed a cell with a literal, use the two-argument overload. A Cell is not an
Expr: reads are always an explicit .get(), so “snapshot vs current value” is visible at
every use.
import { arg, evscript, t } from '@maxencerb/evs';
const clamp = evscript({ name: 'clamp', args: [arg('x', t.uint256)] }, (s) => { const acc = s.let(t.uint256, 0n); // typed literal init const copy = s.let(s.args.x); // type inferred: Cell<'uint256'> s.if( s.args.x.gt(100n), () => acc.set(100n), () => acc.set(s.args.x), ); return s.return({ clamped: acc.get(), original: copy.get() });});s.newArray
Section titled “s.newArray”newArray<const e extends WordType>(elem: e, length: IntoExpr<'uint256'>): MutArray<e>;Allocates a zero-filled mutable array of word-type elements with a runtime (or literal) length. This is the building block for “loop over inputs, collect outputs” — the multicall replacement pattern (see token balances).
- Runtime lengths of
2^32or more revert with Panic0x41(allocation too large). - A literal length of
2^32or more throws at recording time (CERTAIN_PANIC). - Allocating inside a loop allocates fresh memory every iteration —
compilewarns withLOOP_ALLOCATION.
import { arg, evscript, t } from '@maxencerb/evs';
const squares = evscript({ name: 'squares', args: [arg('n', t.uint256)] }, (s) => { const out = s.newArray(t.uint256, s.args.n); // zero-filled uint256[n] s.for({ type: t.uint256, from: 0n, until: s.args.n }, (i) => { out.set(i, i.mul(i)); }); return s.return({ squares: out.expr() });});env(kind: 'address' | 'caller' | 'timestamp' | 'blocknumber' | 'chainid'): Expr<'address'> /* for 'address' | 'caller' */ | Expr<'uint256'> /* for the rest */;Reads execution-environment values. 'address' and 'caller' produce Expr<'address'>; the
other kinds produce Expr<'uint256'>. An unknown kind throws EvsTypeError.
import { evscript } from '@maxencerb/evs';
const ctx = evscript({ name: 'ctx', args: [] }, (s) => s.return({ me: s.env('caller'), // frame-dependent here: s.env('address'), // frame-dependent ts: s.env('timestamp'), block: s.env('blocknumber'), chain: s.env('chainid'), }),);s.add / s.sub / s.mul / s.div / s.mod
Section titled “s.add / s.sub / s.mul / s.div / s.mod”add<t extends NumericType>(a: IntoExpr<t>, b: IntoExpr<t>): Expr<t>; // likewise sub/mul/div/modFree-function mirrors of the checked arithmetic Expr methods, for literal-left operands
(s.sub(10_000n, x) — a method cannot put the literal on the left). Same semantics: overflow
or underflow reverts Panic 0x11; division or modulo by zero reverts Panic 0x12; signed
minN / -1 reverts Panic 0x11. See arithmetic.
At least one operand must be an Expr — an all-literal call throws EvsTypeError (“type a
literal with s.lit”). When one operand is a literal-valued Expr, the operation folds at
recording, and a fold that would certainly panic throws CERTAIN_PANIC instead of compiling a
guaranteed revert.
import { arg, evscript, t } from '@maxencerb/evs';
const remaining = evscript({ name: 'remaining', args: [arg('used', t.uint256)] }, (s) => s.return({ left: s.sub(10_000n, s.args.used) }),);s.lt / s.gt / s.lte / s.gte / s.eq / s.neq
Section titled “s.lt / s.gt / s.lte / s.gte / s.eq / s.neq”lt<t extends NumericType>(a: IntoExpr<t>, b: IntoExpr<t>): Expr<'bool'>; // likewise gt/lte/gteeq<t extends WordType>(a: IntoExpr<t>, b: IntoExpr<t>): Expr<'bool'>; // likewise neqComparison mirrors. Ordering comparisons are numeric-only; signed vs unsigned EVM opcodes
(LT/GT vs SLT/SGT) are chosen from the static type. eq/neq accept any word type
(address, bool, bytesN included) but not string/bytes/arrays — there is no deep
equality in v0. Operand types must match exactly; the error message suggests toUint/toInt
when they do not.
s.and / s.or / s.not
Section titled “s.and / s.or / s.not”and(a: IntoExpr<'bool'>, b: IntoExpr<'bool'>): Expr<'bool'>; // likewise ornot(a: IntoExpr<'bool'>): Expr<'bool'>;Boolean logic on Expr<'bool'> values. Eager, not short-circuiting — both operands are
already-computed values by the time and/or records. For conditional execution use
s.if.
s.bitAnd / s.bitOr / s.bitXor / s.bitNot / s.shl / s.shr
Section titled “s.bitAnd / s.bitOr / s.bitXor / s.bitNot / s.shl / s.shr”bitAnd<t extends BitsType>(a: IntoExpr<t>, b: IntoExpr<t>): Expr<t>; // likewise bitOr/bitXorbitNot<t extends BitsType>(a: Expr<t>): Expr<t>;shl<t extends BitsType>(a: Expr<t>, bits: IntoExpr<'uint256'>): Expr<t>; // likewise shrBitwise mirrors over BitsType (uintN or bytesN). Results are re-canonicalized to the
operand’s width; shifts never panic — bits shifted out are dropped. Details and the
per-type lane semantics: arithmetic.
if(cond: IntoExpr<'bool'>, then: () => void, otherwise?: () => void): void;Runtime branch combinator. cond is a plain value, evaluated once before the branch. The
then/otherwise callbacks run immediately at recording time to capture each branch’s
statements; on-chain, only the taken branch executes. Values recorded inside a branch are
scoped to it — use a cell to get a value out.
s.while
Section titled “s.while”while(cond: () => IntoExpr<'bool'>, body: (loop: LoopCtl) => void): void;Runtime loop. The condition is a thunk: the ops it records land in the loop header and re-execute every iteration. Values recorded in the header are visible in the body; nothing recorded inside the loop (header or body) is visible after it — carry state in cells.
import { arg, evscript, t } from '@maxencerb/evs';
const log2 = evscript({ name: 'log2', args: [arg('x', t.uint256)] }, (s) => { const v = s.let(t.uint256, s.args.x); const bits = s.let(t.uint256, 0n); s.while( () => v.get().gt(1n), // recorded once, executes every iteration () => { v.set(v.get().div(2n)); bits.set(bits.get().add(1n)); }, ); return s.return({ bits: bits.get() });});for<const t extends NumericType>( range: { type: t; from: IntoExpr<t>; until: IntoExpr<t>; step?: IntoExpr<t> }, body: (i: Expr<t>, loop: LoopCtl) => void,): void;Counted-loop sugar over s.while plus an internal cell, generic over any numeric word type.
Iterates while i < until; step defaults to 1. until and step are snapshot once,
before the loop. The step increment uses checked arithmetic — if the counter would overflow its
type before reaching until, the script panics 0x11. loop.continue() executes the step
first, then jumps to the header.
See the s.newArray example above for the canonical collect-into-array loop.
s.select
Section titled “s.select”select<t extends EvsType>(cond: IntoExpr<'bool'>, a: IntoExpr<t>, b: IntoExpr<t>): Expr<t>;Value-level conditional: returns a when cond is true, else b. Both sides are eager —
they are already-computed values, so this never skips work (use s.if plus a cell for
conditional execution). Branch types must match exactly, and at least one branch must be an
Expr. The classic use is a default for a failed s.tryCall.
s.call
Section titled “s.call”call<const abi extends Abi | readonly unknown[], name extends ContractFunctionName<abi, 'pure' | 'view'>>(p: { readonly address: IntoExpr<'address'>; readonly abi: abi; readonly functionName: name; // autocomplete union over view/pure functions readonly args?: SubcallInputs; // per parameter: ABI primitive literal OR Expr of that type readonly gas?: IntoExpr<'uint256'>; // optional cap; default forward-all}): CallOutputs; // outputs [] → void; [one] → Expr<one>; [many] → readonly tuple of ExprsA STATICCALL to another contract, typed like viem’s readContract (SubcallInputs and
CallOutputs stand in for package-internal helper types). The address can itself be an
Expr — values flow between calls on-chain, which is the whole point. Key semantics
(exhaustive treatment in calls):
- Only
view/purefunctions are offered; nonpayable/payable names are TypeScript errors. - Callee reverts bubble verbatim (Error/Panic/custom alike).
- Structurally malformed returndata reverts
EvsDecodeError(site);explainRevertmaps the site back to your source line. - Dirty high bits in word outputs are normalized, not reverted.
- A non-
as constABI degrades gracefully:functionName: string, untyped args, outputs asreadonly Expr[]— never a hard type error. - Overloaded function names and ABI parameter types outside v0 throw
EvsTypeErrorat recording.
import { arg, evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const tokenInfo = evscript( { name: 'tokenInfo', args: [arg('token', t.address), arg('owner', t.address)] }, (s) => { const symbol = s.call({ address: s.args.token, abi: erc20Abi, functionName: 'symbol' }); // symbol: Expr<'string'> const bal = s.call({ address: s.args.token, abi: erc20Abi, functionName: 'balanceOf', args: [s.args.owner], // literals and Exprs mix freely gas: 100_000n, // optional per-call gas cap }); return s.return({ symbol, bal }); },);s.tryCall
Section titled “s.tryCall”tryCall<const abi extends Abi | readonly unknown[], name extends ContractFunctionName<abi, 'pure' | 'view'>>( p: SubcallParams, // identical to s.call): { readonly success: Expr<'bool'>; readonly value: CallOutputs };Like s.call, but failure becomes data instead of a revert. success is false when the call
fails or when the returndata is structurally malformed; value is then zeros, empty
strings, or empty arrays — always safe to use. (This is stricter than Solidity’s try/catch,
which would surface malformed returndata differently.)
import { arg, evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const tokenDecimals = evscript({ name: 'tokenDecimals', args: [arg('token', t.address)] }, (s) => { const d = s.tryCall({ address: s.args.token, abi: erc20Abi, functionName: 'decimals' }); return s.return({ decimals: s.select(d.success, d.value, 18) }); // default on failure});fn<const params extends readonly ArgSpec[], const r extends Expr | readonly Expr[] | void>( name: string, params: params, body: (...args: { [i in keyof params]: Expr<params[i]['type']> }) => r,): (...args: { [i in keyof params]: IntoExpr<params[i]['type']> }) => FreshHandles<r>;Defines a reusable typed subroutine and returns a callable handle (FreshHandles stands in for
a package-internal helper: Expr results come back as fresh Exprs, tuples as fresh tuples,
void as void). Rules (full guide):
- The body runs once, at definition, in an isolated scope: params only, no capture of
outer
Exprs, cells, ors.args(EvsScopeErrorat recording). - Each call of the returned handle records one statement and returns fresh handles — two calls never alias.
- Compiled as a
JUMPDESTsubroutine: code is emitted once regardless of call count; uncalled fns are dropped. Recursion is unconstructible.
import { arg, evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const pairBalances = evscript( { name: 'pairBalances', args: [arg('a', t.address), arg('b', t.address), arg('who', t.address)] }, (s) => { const balOf = s.fn( 'balOf', [arg('token', t.address), arg('owner', t.address)] as const, (token, owner) => s.call({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [owner] }), ); return s.return({ balA: balOf(s.args.a, s.args.who), balB: balOf(s.args.b, s.args.who), }); },);s.return
Section titled “s.return”return<const ret extends Record<string, Expr>>(values: ret): ScriptReturn<ret>;Declares the script’s outputs and seals the recorder. Must be called exactly once,
unconditionally, at the top level of the callback (not inside s.if/s.while/s.for bodies,
not inside an s.fn body), and its result must be what the callback returns.
- Record keys become the named components of the single tuple output; viem consumers receive an
object. Keys must be identifiers; empty keys are rejected (
ABI_SHAPE) because an unnamed component silently degrades viem’s result to a positional array. - Every value must be an
Expr— type literals withs.litfirst. - After sealing, any builder call throws
EvsScopeError(RECORDING_CLOSED).
import type { EvsType, Expr, IntoExpr } from '@maxencerb/evs';
interface Cell<t extends EvsType> { readonly type: t; get(): Expr<t>; // fresh snapshot at this program point set(value: IntoExpr<t>): void;}Returned by s.let. get() records a read — the resulting Expr is a snapshot of
the cell at that program point, not a live reference. A cell is only usable while its
defining scope is on the recording stack; touching it elsewhere throws EvsScopeError.
MutArray
Section titled “MutArray”import type { Expr, IntoExpr, WordType } from '@maxencerb/evs';
interface MutArray<e extends WordType> { readonly elemType: e; readonly length: Expr<'uint256'>; set(i: IntoExpr<'uint256'>, v: IntoExpr<e>): void; // bounds-checked → Panic 0x32 get(i: IntoExpr<'uint256'>): Expr<e>; // bounds-checked → Panic 0x32 expr(): Expr<`${e}[]`>; // handle to the SAME buffer (reference semantics)}Returned by s.newArray. All indexed access is bounds-checked (Panic 0x32).
expr() returns a plain Expr array handle aliasing the same buffer — later set() calls are
visible through it. length is recorded once, at construction.
LoopCtl
Section titled “LoopCtl”interface LoopCtl { break(): void; // jump past the owning loop continue(): void; // jump to the owning loop's header (s.for: runs the step first)}Passed to s.while/s.for bodies. Scoped: calling either method outside the recording of its
owning loop’s body throws EvsScopeError.
ScriptReturn
Section titled “ScriptReturn”import type { Expr } from '@maxencerb/evs';
declare const returnBrand: unique symbol;interface ScriptReturn<ret extends Record<string, Expr>> { readonly [returnBrand]: ret;}The opaque branded token produced by s.return and required as the callback’s
return value. It exists purely so the type system can thread the return record’s literal type
into EvsScript — you never construct or inspect one.