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.
Script arguments — positional callback params
Section titled “Script arguments — positional callback params”Script args are declared on the header as t.* types (a lone type or a readonly list) and
arrive as positional parameters after s in the body callback — there is no s.args. A
scalar arg arrives as an Expr; a TupleType arg as a Tuple handle. Property
access records nothing — argument values are decoded and validated once at script entry. See
the evscript reference for the full signature.
import { evscript, t } from '@maxencerb/evs';
const sum = evscript( { name: 'sum', args: [t.uint256, t.uint256] }, (s, a, b) => s.return({ total: a.add(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, except
an all-zero literal (an all-zero uint256[N], 0x), which is a zero-filled allocation and
costs no data segment. A sparse literal (at least half zero words) is zero-filled plus one store
per nonzero word when that is the smaller code (each store costs about 17 gas per
materialization, so hoist a sparse literal out of a hot s.for body). A
t.struct / t.tuple literal and a composite-array literal (tuple[], T[][], string[]) are
built at record time into a fresh block, exactly as when they are passed to a call or returned.
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. There is no
init-less form: a zero array cell is s.let(t.array(t.uint256, n), zeros) (a zero literal is a
zero-fill, see s.lit) or s.let(s.newArray(t.uint256, n, { fixed: true }).expr()). The type can be any value
type, a t.struct / t.tuple / tuple[] included (s.let(Pos, { liquidity: 0n, owner })); a
composite cell holds a pointer, so .set rebinds it to another value. A Cell is not an
Expr: reads are always an explicit .get(), so “snapshot vs current value” is visible at
every use.
import { evscript, t } from '@maxencerb/evs';
const clamp = evscript({ name: 'clamp', args: [t.uint256] }, (s, x) => { const acc = s.let(t.uint256, 0n); // typed literal init const copy = s.let(x); // type inferred: Cell<'uint256'> s.if( x.gt(100n), () => acc.set(100n), () => acc.set(x), ); return s.return({ clamped: acc.get(), original: copy.get() });});s.newArray
Section titled “s.newArray”newArray<const e extends EvsType>(elem: e, length: IntoExpr<'uint256'>): MutArray<e>;newArray<const e extends EvsType, const n extends number>( elem: e, length: n, opts: { readonly fixed: true },): MutArray<e, n>;Allocates a zero-filled mutable array with a runtime (or literal) length. elem may be any value
type — a word type, string/bytes, a struct/tuple (the array is then a tuple[]), or an array
(uint256[] → uint256[][], t.array(Position) → tuple[][], t.array(t.uint256, 2) →
uint256[2][]), as long as the result stays within four array levels (UNSUPPORTED_V0
otherwise). With { fixed: true } and a literal positive length n, it allocates a fixed-size
elem[n] instead (expr() is an Expr<elem[n]>). Unset slots hold their typed zero. 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. - A composite element (
tuple,string/bytes,T[]) makes the array an array of pointers;get(i)returns the element handle (aTuplefor a tuple element), a reference into sloti, andset(i, v)takes the element’sIntoMember(aTuple/literal for a tuple element). - Every element starts at its zero value:
0for a word, an empty string/bytes/array for astring/bytes/T[]element, and a zeroed struct for a tuple element (its own dynamic members empty, nested structs zeroed). Each tuple element is a distinct block, so writing a field throughget(i)never shows up in another element.
import { evscript, t } from '@maxencerb/evs';
const squares = evscript({ name: 'squares', args: [t.uint256] }, (s, n) => { const out = s.newArray(t.uint256, n); // zero-filled uint256[n] s.for({ type: t.uint256, from: 0n, until: n }, (i) => { out.set(i, i.mul(i)); }); return s.return({ squares: out.expr() });});A tuple[] array — get(i) returns the element Tuple, filled in place:
import { evscript, t } from '@maxencerb/evs';
const Position = t.struct({ liquidity: t.uint128, owner: t.address });
const positions = evscript({ name: 'positions', args: [t.address, t.uint256] }, (s, owner, n) => { const out = s.newArray(Position, n); // tuple[n]: each slot a distinct zeroed struct s.for({ type: t.uint256, from: 0n, until: n }, (i) => { const el = out.get(i); // Tuple handle into slot i el.liquidity.set(i.toUint(t.uint128)); el.owner.set(owner); }); return s.return({ positions: out.expr() }); // readonly { liquidity; owner }[]});s.tuple
Section titled “s.tuple”tuple<const c extends TupleType>(type: c, init?: TupleInit<c>): Tuple<c>;Allocates a tuple/struct in memory and returns a Tuple handle. type is a
TupleType — a t.struct/t.tuple (or a raw
readonly AbiParameter[]). init is a partial set of members: an object keyed by member
name when every member is named (a t.struct), else a positional array (a t.tuple, or an ABI
tuple with any unnamed member: abitype and viem’s rule). Every member is optional, defaults to its
zero value, and accepts a literal, an Expr, or a nested Tuple. A key that names no member, an
element past the last member, or a staged handle in place of the init (a Cell, MutArray,
Field, Tuple or Expr) throws TYPE_MISMATCH. So does a spread or Object.assign copy of
a handle that leaves a member out ({ ...pos, owner }): a handle keeps its fields on its
prototype, so the copy holds only the members written next to the spread and would zero-fill the
rest. For a Tuple handle that is also a type error; for an Expr, Cell or Field of the
struct ({ ...pos.expr(), owner }) only the runtime check catches it. Read each field with
.get() instead; a copy that names every member loses nothing and is accepted. Use the
returned handle’s Field.set to fill or overwrite members later.
import { evscript, t } from '@maxencerb/evs';
const Position = t.struct({ liquidity: t.uint128, owner: t.address });
const mkPos = evscript({ name: 'mkPos', args: [t.address] }, (s, owner) => { const pos = s.tuple(Position, { liquidity: 42n, owner }); // alloc + zero-fill + set members pos.liquidity.set(pos.liquidity.get().add(1n)); // read-modify-write a field return s.return({ liq: pos.liquidity.get(), pos }); // a field, and the whole struct (returned directly)});Omitted members start at their zero value: 0 for a word member (the allocation is zero-filled,
so this costs no extra write), an empty string/bytes/array for a string/bytes/T[] member,
and a fresh zeroed struct for a nested tuple member. A tuple
handle is a pointer with reference semantics: copying it (or passing it to a call) shares the
same block, so a later .set() is visible through every alias. See
composite types in values & types.
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' }, (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'), }),);'blocknumber' is the NUMBER opcode, so it reports what the chain defines: on Arbitrum an
approximate L1 block number, not the L2 block. Read ArbSys(0x64).arbBlockNumber() for the L2
height — see s.env and frame dependence.
s.balance / s.codeSize / s.codeHash
Section titled “s.balance / s.codeSize / s.codeHash”balance(account: IntoExpr<'address'>): Expr<'uint256'>;codeSize(account: IntoExpr<'address'>): Expr<'uint256'>;codeHash(account: IntoExpr<'address'>): Expr<'bytes32'>;Read the state of any address — Solidity’s a.balance, a.code.length and a.codehash.
The operand is an Expr<'address'> or an address literal; anything else throws EvsTypeError.
s.balance(a)— the native balance in wei (BALANCE). No Multicall3getEthBalancedetour needed.s.codeSize(a)— the size of the deployed code in bytes (EXTCODESIZE):0for an EOA, an address with no state, or a contract still in its constructor.s.codeHash(a)—keccak256of the code (EXTCODEHASH). An account that exists but has no code (an EOA with a balance or a nonce) hashes tokeccak256(0x)(0xc5d2…a470); an account that does not exist reads asbytes32(0).
import { evscript, t } from '@maxencerb/evs';
const account = evscript({ name: 'account', args: [t.address] }, (s, who) => s.return({ wei: s.balance(who), isContract: s.codeSize(who).gt(0n), codeHash: s.codeHash(who), }),);Each read is one opcode: 2,600 gas the first time an address is touched in the call, 100 after
(EIP-2929). codeSize(a) > 0 is the usual “is this a contract” check, with the usual caveats:
an EOA that delegates through an EIP-7702 authorization has a 23-byte designator as its code,
and a contract mid-construction reports 0. s.tryRead cannot make that distinction for you —
a call to an EOA succeeds with empty returndata.
s.balance(s.env('address')) — the script’s own balance — compiles to SELFBALANCE and is
frame-dependent: in the default deployless mode the script runs at a fresh counterfactual
address (normally holding nothing), in state-override mode at the override address, whose
balance you can set in the same override. compile emits an ENV_FRAME_DEPENDENT diagnostic
for it; see execution modes. The script’s own
code size and hash are the same in both modes.
In sender mode (toViem({ mode: 'stateOverride', sender }))
the script’s runtime replaces the code at sender for the call, so reads of the sender’s own
account see the script, not the account: s.codeSize(sender) is non-zero even for an EOA (it
looks like a contract), and s.codeHash(sender) is the script’s hash, not a Safe’s real one.
s.balance(sender) — which is s.balance(s.env('address')) there — is the sender’s real balance,
since the override only swaps code. No diagnostic catches this when sender reaches the script
as an argument; read the sender’s code in a separate deployless call if you need it.
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 { evscript, t } from '@maxencerb/evs';
const remaining = evscript({ name: 'remaining', args: [t.uint256] }, (s, used) => s.return({ left: s.sub(10_000n, used) }),);s.pow / s.addmod / s.mulmod
Section titled “s.pow / s.addmod / s.mulmod”pow<t extends NumericType>(base: Expr<t>, exponent: IntoExpr<'uint256'> | Expr<UintType>): Expr<t>;addmod(a: IntoExpr<'uint256'>, b: IntoExpr<'uint256'>, modulus: IntoExpr<'uint256'>): Expr<'uint256'>; // likewise mulmodMirrors of the Expr methods. pow is checked like solc’s **: Panic 0x11 when the power
leaves the base type’s range (intN included — (-2) ** 7 fits int8, 2 ** 7 does not),
0 ** 0 == 1. The base must be an Expr (its type is the result type — type a literal base
with s.lit); the exponent is unsigned, a literal or any Expr<'uintN'>. addmod / mulmod
are uint256-only and compute (a + b) % n / (a * b) % n without wrapping the intermediate;
a zero modulus reverts Panic 0x12. Any operand may be a literal; all-literal calls fold at
recording (a literal zero modulus there is CERTAIN_PANIC). See
arithmetic.
s.mulDiv / s.mulDivRoundingUp
Section titled “s.mulDiv / s.mulDivRoundingUp”mulDiv(a: IntoExpr<'uint256'>, b: IntoExpr<'uint256'>, denominator: IntoExpr<'uint256'>): Expr<'uint256'>; // likewise mulDivRoundingUpMirrors of the Expr methods: ⌊a · b / d⌋ / ⌈a · b / d⌉ over a 512-bit intermediate
(FullMath), so the product never overflows on its own. A zero denominator reverts Panic 0x12
and a quotient past uint256 reverts Panic 0x11 (OpenZeppelin Math.mulDiv’s codes). Any
operand may be a literal; all-literal calls fold at recording, and a certain Panic there is
CERTAIN_PANIC. See arithmetic.
s.wrappingAdd / s.wrappingSub / s.wrappingMul
Section titled “s.wrappingAdd / s.wrappingSub / s.wrappingMul”wrappingAdd<t extends NumericType>(a: IntoExpr<t>, b: IntoExpr<t>): Expr<t>; // likewise wrappingSub/wrappingMulMirrors of the wrapping Expr methods, Solidity’s a + b inside an unchecked block: the
result modulo 2^N of t, never a Panic (s.wrappingSub(0n, x) is two’s complement negation).
Operand rules are the checked ops’ (at least one Expr, one type); a fold over literal-valued
operands wraps too, where the checked op would be CERTAIN_PANIC. See
arithmetic.
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 OrderedType>(a: IntoExpr<t>, b: IntoExpr<t>): Expr<'bool'>; // likewise gt/lte/gteeq<t extends EvsType>(a: IntoExpr<t>, b: IntoExpr<t>): Expr<'bool'>; // likewise neqComparison mirrors. Ordering comparisons take an OrderedType — numeric, address or bytesN
(Solidity’s domain; bool and memrefs have no order); signed vs unsigned EVM opcodes
(LT/GT vs SLT/SGT) are chosen from the static type, and address / bytesN compare as
unsigned words. eq/neq accept any word type
(address, bool, bytesN included) as a single-word compare, and every memref type
(string, bytes, T[], structs) as hash equality: the recorded stmts are those of
s.keccak256(a).eq(s.keccak256(b)) — byte equality for string/bytes (raw bytes hashed),
element-wise equality via the standard abi.encode for arrays and structs (two keccaks per
comparison). A constant literal operand (a string/bytes literal, or a word-element array of
literals) has its hash computed at record time and recorded as a bytes32 constant, so only the
other side is hashed at run time; struct literals and arrays holding an Expr are built and
hashed at run time. Operand types must match exactly (structurally for structs); the error message
suggests toUint/toInt when two numeric widths differ.
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 | IntType>(a: Expr<t>, bits: IntoExpr<'uint256'>): Expr<t>; // likewise shrBitwise mirrors over BitsType (uintN or bytesN); the shifts also take intN, where shr
is the arithmetic SAR (rounds toward −∞, like Solidity’s >>). 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.
s.encode / s.encodePacked / s.keccak256
Section titled “s.encode / s.encodePacked / s.keccak256”encode(...values: [EncodeValue, ...EncodeValue[]]): Expr<'bytes'>; // abi.encodeencodePacked(...values: [PackedValue, ...PackedValue[]]): Expr<'bytes'>; // abi.encodePackedkeccak256(...values: [EncodeValue, ...EncodeValue[]]): Expr<'bytes32'>; // keccak256(abi.encode(...))Byte-exact mirrors of Solidity’s encoding and hashing primitives. EncodeValue is any staged
handle (Expr | Tuple | MutArray); PackedValue excludes Tuple (packed mode carries
Solidity’s restrictions — words, string/bytes, and word-element arrays only). At least one
value is required, and literals must be lifted with s.lit. s.keccak256 hashes the
standard encoding — keccak256(abi.encode(...)), structs included — except that a lone
bytes/string value is hashed directly (Solidity’s keccak256(b)); the non-standard packed
hash is always the explicit s.keccak256(s.encodePacked(...)) composition. Semantics and the
packed rules: hashing & ABI encoding.
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 { evscript, t } from '@maxencerb/evs';
const log2 = evscript({ name: 'log2', args: [t.uint256] }, (s, x) => { const v = s.let(t.uint256, 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( range: { from: IntoExpr<'uint256'>; until: IntoExpr<'uint256'>; step?: IntoExpr<'uint256'> }, body: (i: Expr<'uint256'>, loop: LoopCtl) => void,): void;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. type is optional and defaults to
uint256 — s.for({ from: 0n, until: n }, (i) => …) gives an Expr<'uint256'> counter; pass
an explicit numeric word type to loop over any other width (including signed types).
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.forEach
Section titled “s.forEach”forEach<C extends TupleType>( array: Expr<C>, // a tuple[] array value body: (elem: Tuple<...>, i: Expr<'uint256'>, loop: LoopCtl) => void,): void;forEach<a extends ArrayType>( array: Expr<a>, // any T[] array value body: (elem: Expr<...element...>, i: Expr<'uint256'>, loop: LoopCtl) => void,): void;Iteration sugar over the counter loop: until is the array’s length (snapshot once, before
the loop) and each iteration binds elem to the bounds-checked array.at(i) element — a
Tuple handle for a tuple[] element, an Expr of the element type otherwise —
alongside the uint256 index i and the usual LoopCtl. It records exactly the
IR the manual s.for + array.at(i) spelling would.
import { evscript, t } from '@maxencerb/evs';
const sum = evscript({ name: 'sum', args: [t.array(t.uint256)] }, (s, xs) => { const total = s.let(t.uint256, 0n); s.forEach(xs, (x) => { total.set(total.get().add(x)); }); return s.return({ total: total.get() });});The array must be a staged Expr handle (a script arg, a decoded call output, an s.lit data
segment, …). A MutArray iterates through its .expr() memref —
s.forEach(arr.expr(), …) — with reference semantics: everything executes in recorded order,
so the loop observes set calls recorded before the loop (and, in-loop, those already executed
by earlier iterations or earlier in the same body); a set recorded after the loop is never
seen.
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.tryRead. When cond is known
at recording (true, or a folded constant), the result is the chosen branch; a literal on the
other side is still checked under the same rules as with a runtime condition, then dropped from
the bytecode.
The three calling verbs
Section titled “The three calling verbs”Sub-calls split by mutability + call frame (issue #1). All six verbs share the same
parameter object and the same struct-aware overloads (default positional / struct: true /
non-literal boolean, plus a try* variant returning { success, value }); they differ only in
the opcode they emit and the functionName mutability bucket they accept — plus the
revertReturns opt-in that only call / tryCall take:
| Verb | Opcode | Mutability bucket | State |
|---|---|---|---|
read / tryRead |
STATICCALL |
view / pure |
static — no write possible |
call / tryCall |
CALL (value 0, or the value you send) |
nonpayable / payable |
real frame; the write is not rolled back — it persists to later sub-calls in the same eth_call |
simulate / trySimulate |
CALL via self-call trampoline |
nonpayable / payable |
the write is rolled back, yet its return value is read back |
The mutability bucket is enforced at the functionName type level: a nonpayable function
under read is a compile error (steered to call/simulate); a view/pure function under
call/simulate is a compile error (steered to read). The full semantics and worked examples
live in calling contracts.
s.read
Section titled “s.read”read<const abi extends Abi | readonly unknown[], name extends SubcallFunctionName<abi, 'pure' | 'view'>, const args extends SubcallInputs>(p: { readonly address: IntoExpr<'address'>; readonly abi: abi; readonly functionName: name; // autocomplete union over view/pure function names + canonical signatures ('get(uint256)') readonly args?: args; // per parameter: ABI primitive/Expr; a tuple param: Tuple/s.tuple/object literal (members may be Exprs); selects the overload readonly gas?: IntoExpr<'uint256'>; // optional cap; default forward-all readonly struct?: boolean; // opt-in: fuse multiple named outputs into ONE named Tuple (ABI order)}): SubcallOutputs; // [] → void; [one] → Expr<one> or Tuple<one>; [many] → readonly tuple of Expr|Tuple (or one Tuple with struct:true)A STATICCALL to another contract, typed like viem’s readContract (SubcallInputs,
SubcallOutputs, SubcallFunctionName and ResolveOverload are exported helper types, generic
over the ABI, function name, mutability and — for the outputs — the inferred args; the excerpt
elides their type arguments). 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 — reach fors.callors.simulatefor those. - A
'tuple'output decodes into aTuplehandle (named/positional field access); a'tuple'argument accepts aTuplehandle, as.tuple(...)result, or an object literal naming every member (each a constant or a runtimeExpr/Tuple). See calls — struct arguments and outputs. - By default a function’s multiple outputs decode to the positional
[many]shape (areadonlytuple ofExpr | Tuple, mirroring viem). Passingstruct: trueinstead fuses them into one namedTuplehandle in ABI declaration order (e.g.s.read({ …, functionName: 'slot0', struct: true }).sqrtPriceX96.get()), so it unifies with at.struct/t.fromOutputs(abi, name)in the same order. Every output must be named, each name once, elseEvsTypeErrorat recording; a non-literalbooleanwidens the result to the union of both shapes.s.tryReadtakesstructtoo. See calls — decoding multiple outputs into a struct. - Callee reverts bubble verbatim (Error/Panic/custom alike).
- Structurally malformed returndata reverts
EvsDecodeError(site);explainRevertmaps the site id back to the failing read. - Dirty high bits in word outputs are normalized, not reverted.
- A non-
as constABI degrades gracefully:functionName: string, untyped args, and outputs typedWideSubcallResult(exported:Expr | Tuple | readonly (Expr | Tuple)[] | undefined) — never a hard type error. At run time the shape follows the real output count (none →undefined, one → a single handle, several → a readonly array), so cast to the shape the function has before use:as Expr<'uint8'>for one output,as readonly [Expr<'uint160'>, Expr<'int24'>, Expr<'bool'>]to destructure several. - With an
as constABI,functionNameaccepts only the names (and canonical signatures) in the verb’s mutability bucket — none at all when the ABI has no function there. - An overloaded name is resolved by the
args’ types (ResolveOverload), and the result is typed from the chosen overload; args fitting several overloads fail to compile and throwEvsTypeError(ABI_SHAPE) at recording, args fitting none of several same-arity overloads fail to compile and throwTYPE_MISMATCH. A canonical signature (functionName: 'get(uint8)') names one overload exactly. See calls — overloaded functions. - ABI parameter types outside the current scope throw
EvsTypeErrorat recording.
import { evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const tokenInfo = evscript( { name: 'tokenInfo', args: [t.address, t.address] }, (s, token, owner) => { const symbol = s.read({ address: token, abi: erc20Abi, functionName: 'symbol' }); // symbol: Expr<'string'> const bal = s.read({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [owner], // literals and Exprs mix freely gas: 100_000n, // optional per-call gas cap }); return s.return({ symbol, bal }); },);s.tryRead
Section titled “s.tryRead”tryRead<const abi extends Abi | readonly unknown[], name extends SubcallFunctionName<abi, 'pure' | 'view'>>( p: SubcallParams, // identical to s.read): { readonly success: Expr<'bool'>; readonly value: SubcallOutputs };Like s.read, 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, empty arrays, or zero-filled tuples — always safe to use. (This is stricter than
Solidity’s try/catch, which would surface malformed returndata differently.) success is not
a range check: an out-of-range word (a uint8 output holding 300) is normalized like under
s.read and reports success = true (see calls — s.tryRead). For a
function with no outputs, a call to an address without code succeeds and success is true (for
every try* verb) — there is nothing to decode, and evs adds no extcodesize check (see
calls); guard with
s.codeSize(target).gt(0n) when it matters.
import { evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const tokenDecimals = evscript({ name: 'tokenDecimals', args: [t.address] }, (s, token) => { const d = s.tryRead({ address: token, abi: erc20Abi, functionName: 'decimals' }); return s.return({ decimals: s.select(d.success, d.value, 18) }); // default on failure});s.call
Section titled “s.call”call<const abi extends Abi | readonly unknown[], name extends SubcallFunctionName<abi, 'nonpayable' | 'payable'>>( p: SubcallParams, // identical to s.read, but functionName is filtered to nonpayable/payable): SubcallOutputs;call<const abi extends Abi | readonly unknown[], name extends SubcallFunctionName<abi, 'nonpayable' | 'payable'>, const rr extends readonly EvsType[]>( p: SubcallParams & { readonly revertReturns: rr; readonly struct?: false },): SubcallOutputs; // typed from `rr`, NOT the ABI outputs: [] → void; [one] → Expr|Tuple; [many] → readonly tupleIdentical encode/decode/verbatim-bubble path to s.read, but emits a CALL
instead of STATICCALL — “read with the CALL opcode”. Use it for nonpayable/payable functions
that can’t run under STATICCALL but don’t usefully persist state: the canonical case is a Uniswap
quoter. There is no rollback — a write done via s.call is visible to a later s.read in the
same script (the same eth_call frame), though the eth_call result is still never committed
on-chain. The target sees msg.sender = the script’s address (frame-dependent — see
calls), or a chosen address in
sender mode. s.tryCall is the { success, value }
variant (with the same no-outputs caveat as s.tryRead).
value — the wei the CALL sends (msg.value; a literal or an Expr<'uint256'>, default 0),
accepted for a payable function only, by s.call / s.simulate and their try* forms. It is
paid from the script’s own balance: fund the script address through a state-override balance.
See calls — sending ETH.
revertReturns (the QuoterV1 pattern) — the output types carried by the target’s revert
data. When set it replaces the ABI outputs as the decode schema (the ABI’s own outputs, if any,
are ignored) and the branches swap: a revert is the value path, decoded with the same head guard and
dynamic-output bounds as returndata; a normal return is the failure (s.call reverts
EvsDecodeError(site) — nothing is bubbled; s.tryCall reports success = false with zeroed
values). s.call / s.tryCall only — s.read* and s.simulate* reject it at recording, as does
combining it with struct: true (declare one t.struct(...) type instead). See
calls — decoding revert data.
import { evscript, t } from '@maxencerb/evs';
const quoterV1Abi = [ { type: 'function', name: 'quoteExactInput', stateMutability: 'nonpayable', inputs: [{ name: 'amountIn', type: 'uint256' }], outputs: [], // QuoterV1 reverts with the encoded quote },] as const;
const quoteV1 = evscript({ name: 'quoteV1', args: [t.address, t.uint256] }, (s, quoter, amountIn) => { const amountOut = s.call({ address: quoter, abi: quoterV1Abi, functionName: 'quoteExactInput', args: [amountIn], revertReturns: [t.uint256], // amountOut: Expr<'uint256'>, decoded from the revert payload }); return s.return({ amountOut });});import { evscript, t } from '@maxencerb/evs';
const quoterAbi = [ { type: 'function', name: 'quoteExactInputSingle', stateMutability: 'nonpayable', // not view → needs s.call, not s.read inputs: [ { name: 'tokenIn', type: 'address' }, { name: 'tokenOut', type: 'address' }, { name: 'amountIn', type: 'uint256' }, { name: 'fee', type: 'uint24' }, ], outputs: [{ name: 'amountOut', type: 'uint256' }], },] as const;
const quote = evscript( { name: 'quote', args: [t.address, t.address, t.address, t.uint256, t.uint24] }, (s, quoter, tokenIn, tokenOut, amountIn, fee) => { const amountOut = s.call({ address: quoter, abi: quoterAbi, functionName: 'quoteExactInputSingle', args: [tokenIn, tokenOut, amountIn, fee], }); return s.return({ amountOut }); },);s.simulate
Section titled “s.simulate”simulate<const abi extends Abi | readonly unknown[], name extends SubcallFunctionName<abi, 'nonpayable' | 'payable'>>( p: SubcallParams, // identical to s.call): SubcallOutputs;Dry-runs a true write and reads back what it would return, then rolls the write back so it is
isolated from later reads in the same script. It lowers to a self-call to ADDRESS() (the script’s
own code) through a reserved trampoline entrypoint: the trampoline performs the real CALL to the
target and then REVERTs, which unwinds the sub-frame and discards every state change — that revert
is the rollback. The carried returndata is recognized by a magic word and decoded normally. As
with s.call, the target sees msg.sender = the script’s address — or a chosen address in
sender mode. Simulate sites compose freely (inside s.fn bodies,
one dry-run feeding the next), and the optional gas bounds the inner target call while the
self-call hop forwards all gas. An optional value rides on the self-call hop and the trampoline
forwards it to the target; it is rolled back with the write. s.trySimulate is the { success, value } variant. Full
mechanism: simulating writes.
import { evscript, t } from '@maxencerb/evs';
const vaultAbi = [ { type: 'function', name: 'deposit', stateMutability: 'nonpayable', inputs: [{ name: 'assets', type: 'uint256' }], outputs: [{ name: 'shares', type: 'uint256' }], },] as const;
const previewDeposit = evscript( { name: 'previewDeposit', args: [t.address, t.uint256] }, (s, vault, assets) => { // What WOULD deposit() mint? Read the return value, then roll the write back. const shares = s.simulate({ address: vault, abi: vaultAbi, functionName: 'deposit', args: [assets] }); return s.return({ shares }); },);fn< const params extends ArgsInput, // a bare type, a single namedArg, or a readonly list mixing both const r extends Expr | Tuple | MutArray | readonly (Expr | Tuple | MutArray)[] | void,>( name: string, params: params, body: (...args: FnArgHandles<NormalizeArgs<params>>) => r, // one ArgHandle per param): EvsFn<NormalizeArgs<params>, r>; // (...args: one IntoMember per param) => FreshHandles<r>Defines a reusable typed subroutine and returns a callable handle. A body may return a single
Expr, a single Tuple (a struct/composite result), a single MutArray (an array result), a
readonly list of those (the [many] shape), or void. (FreshHandles stands in for a
package-internal helper: Expr results come back as fresh Exprs, Tuple results as fresh
Tuple handles, array/MutArray results as fresh array Exprs at the call site, and void as
void.) Rules (full guide):
- The body runs once, at definition, in an isolated scope: params only, no capture of
outer
Exprs or cells (EvsScopeErrorat recording). s.fnparams accept a baret.*type, a singlenamedArg(name, type), or areadonlylist mixing named/bare (issue #9) — anamedArglabels the callback parameter, a bare param keeps the positionalarg{i}name.- Params take any evs type, like script args: the body handle is the same
ArgHandledispatch — at.struct/t.tupleparam is aTuplehandle, a composite array or scalar anExpr. The call site accepts anIntoMemberof the param type: anIntoExprfor scalars, an arrayExpr/literal/MutArrayfor arrays, and aTuplehandle or object literal (members may be runtime values) for a struct (passed by reference — the memref pointer word; members are checked structurally at 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. - When recording the body throws (a bad literal, an invalid result), the definition is discarded,
so a script may catch the error and carry on without it. The exception is a body that defined
its own nested
s.fnbefore it failed: that script cannot be finished, andevscriptthrowsEvsScopeError(SCOPE_VIOLATION).
import { namedArg, evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const pairBalances = evscript( { name: 'pairBalances', args: [t.address, t.address, t.address] }, (s, a, b, who) => { const balOf = s.fn( 'balOf', [namedArg('token', t.address), namedArg('owner', t.address)] as const, (token, owner) => s.read({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [owner] }), ); return s.return({ balA: balOf(a, who), balB: balOf(b, who), }); },);s.throw
Section titled “s.throw”throw<const e extends errs[number]>(error: e, ...args: ThrowArgs<e>): void;// errs: the script's declared errors (the def's `errors: [...]`)Reverts with a declared custom error: selector ‖ abi.encode(args), exactly Solidity’s
revert NoBalance(...) encoding. Only errors listed in the def’s errors are accepted — any
other t.error value is a TypeScript error (and ERROR_UNDECLARED at recording for untyped
callers). ThrowArgs<e> (exported) shapes the arguments:
- every param named (
namedArg) → one required, name-keyed record; - any bare param → a positional tuple;
- zero params → no argument.
Arguments are IntoExpr/handle values like everywhere else; struct and array params work. A
throw is usually conditional (inside s.if); recording continues after it, and statements
recorded after an unconditional throw in the same block are dead. Errors thrown inside an
s.fn body are checked against the same declared set. The declared errors are appended to the
script ABI, so viem decodes them natively; see
errors and debugging
for matchScriptError/decodeScriptError on the client.
import { evscript, namedArg, t } from '@maxencerb/evs';
const TooSmall = t.error('TooSmall', [namedArg('value', t.uint256)]);
const guard = evscript({ name: 'guard', args: [t.uint256], errors: [TooSmall] }, (s, x) => { s.if(x.lt(10n), () => { s.throw(TooSmall, { value: x }); }); return s.return({ x });});s.return
Section titled “s.return”// each value is an Expr, a Tuple handle, OR a MutArray handle — directly:// `NonEmptyReturn<ret>` makes `s.return({})` a type error, `NoProtoKey<ret>` a `__proto__` keyreturn<const ret extends Record<string, Expr | Tuple<TupleType> | MutArray<EvsType>>>( values: ret & NonEmptyReturn<ret> & NoProtoKey<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 other than
__proto__(viem’s decoder would drop that key: a literal__proto__:key is a type error, and throwsABI_SHAPEat recording when its value is a handle); empty keys are rejected (ABI_SHAPE) because an unnamed component silently degrades viem’s result to a positional array. - The record must name at least one value:
s.return({})is a type error and throwsABI_SHAPEat recording. An empty output tuple ABI-encodes to0x, which viem rejects as “returned no data” (easily mistaken for a missing contract). A guard-only script, one that only reverts or succeeds, returns a flag instead:s.return({ ok: s.lit(t.bool, true) }). - Each value is an
Expr, or aTuplehandle directly — a struct/tuple flows out as a'tuple'component, abitype-typed. (tupleHandle.expr()is equivalent and still valid, for the bare memrefExpr.) Type bare literals withs.litfirst. - A
MutArrayhandle is also returnable directly (no.expr()) — the array flows out as its abitype-typed array shape;arr.expr()is equivalent and still valid. The per-value type isReturnValue = Expr | Tuple | MutArray. - 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”interface MutArray<e extends EvsType> { readonly elemType: e; readonly length: Expr<'uint256'>; set(i: IntoExpr<'uint256'>, v: IntoMember<e>): void; // bounds-checked → Panic 0x32 get(i: IntoExpr<'uint256'>): MutArrayElem<e>; // bounds-checked → Panic 0x32 expr(): Expr<MutArrayValueOf<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. For a word element the slots
are inline values: set/get take/return an Expr<e> and expr() is the array Expr (the
e[] handle). For a
composite element (tuple/string/bytes/T[]) the array is an array of pointers: get(i)
returns the element handle (a Tuple for a tuple element) — a reference into slot i —
and set(i, v) takes the element’s IntoMember (a Tuple handle / literal for a tuple element).
type Tuple<C extends TupleType> = { // one property per NAMED component → a Field over that member: readonly [name in NamedComponents<C>]: Field<MemberType<name>>; at(i: number): Field<…>; // positional accessor (literal index) expr(): Expr<C>; // the raw memref — for s.return or passing as a call arg};A tuple/struct memref handle. Produced by s.tuple, by a TupleType script arg, and
by a 'tuple' output of s.read. For each named component it exposes a property
keyed by the component name returning a Field; at(i) is the positional accessor
(i is a recording-time literal number in [0, memberCount) — tuples are flat with a fixed
member count, so there is no runtime member indexing; passing a non-literal throws TYPE_MISMATCH.
Use an array’s .at(Expr) for runtime indexing); and expr() is the raw Expr<C> memref. The handle is itself
returnable from s.return and passable as a 'tuple' call argument directly — expr() is
only needed when you want the bare Expr<C>.
A tuple handle is a pointer with reference semantics (passing it shares the block).
Field names the handle already uses. A named component whose name is a member of the handle
itself gets no field accessor: the handle member wins, so at(i) and expr() keep working
and the field is read through at(i) like a positional member. The names are the methods at
and expr, then (so the handle never looks like a promise), the staging traps toString,
valueOf and toJSON, and the other Object.prototype members (constructor, __proto__,
hasOwnProperty, isPrototypeOf, propertyIsEnumerable, toLocaleString and the legacy
__defineGetter__ family). The Tuple type drops the same names, so pos.at.get() on a field
named at is a type error, not a runtime one. This keeps third-party ABIs with such members
usable; the ABI, the encoding and viem’s result object are unaffected. __proto__ is the one
name evs rejects: in every name you declare (t.struct fields, namedArg, t.error, s.fn
params, s.return keys) and wherever a third-party struct carrying it would enter the script’s
own ABI, because assigning that key on a JavaScript object (viem’s decoded result included)
replaces the object’s prototype instead of storing the value. A computed key ([key]: …) or a
namedArg name throws EvsTypeError at recording. A literal { __proto__: … } key in a
t.struct spec or an s.return record is a type error; at runtime JavaScript never makes it a
key: an object value replaces the record’s prototype, which evs detects and rejects when it is
what the record would hold (a type in a t.struct spec: TYPE_MISMATCH; a handle in an
s.return record: ABI_SHAPE), while a primitive value such as t.uint256 is dropped before evs
sees the record, so only the type check catches that one. Any other prototype loses nothing: a
null one (Object.create(null)) or a class instance’s record is read through its own keys.
import { evscript, t } from '@maxencerb/evs';
const Order = t.struct({ at: t.uint64, expr: t.uint256, size: t.uint128 });
const readOrder = evscript({ name: 'readOrder', args: [Order] }, (s, order) => { return s.return({ // the `at` FIELD, by position; `at(i)` is not narrowed per index, so the value is typed as // the union of the member types (Expr<'uint64'> | Expr<'uint256'> | Expr<'uint128'>) at: order.at(0).get(), size: order.size.get(), // an ordinary field keeps its accessor order: order.expr(), // `expr()` is still the method });});import { evscript, t } from '@maxencerb/evs';
const slot0Abi = [ { type: 'function', name: 'slot0Struct', stateMutability: 'view', inputs: [], outputs: [ { name: '', type: 'tuple', components: [ { name: 'sqrtPriceX96', type: 'uint160' }, { name: 'tick', type: 'int24' }, { name: 'unlocked', type: 'bool' }, ], }, ], },] as const;
const readSlot0 = evscript({ name: 'readSlot0', args: [t.address] }, (s, pool) => { const slot0 = s.read({ address: pool, abi: slot0Abi, functionName: 'slot0Struct' }); // ^? Tuple<…> — the 'tuple' output decoded into a handle return s.return({ tick: slot0.tick.get(), // Expr<'int24'> by name unlocked: slot0.at(2).get(), // Expr<'bool'> by position slot0, // a Tuple handle is returnable directly — the whole struct flows out, abitype-typed });});interface Field<t extends EvsType> { get(): t extends TupleType ? Tuple<t> : Expr<t>; set(value: IntoMember<t>): void; // IntoExpr<t> for scalars; a Tuple/literal for composite members}A handle over one tuple member (Cell-like). get() reads the member: a scalar member yields an
Expr, a nested-tuple member yields a Tuple handle. set(v) writes it — tupleset under the
hood, so writes are visible through every alias of the owning tuple. Obtained from a
Tuple by name (pos.liquidity) or position (pos.at(0)).
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/s.forEach bodies. Scoped: calling either method outside the recording of its
owning loop’s body throws EvsScopeError.
ScriptReturn
Section titled “ScriptReturn”import type { ReturnValue } from '@maxencerb/evs';
declare const returnBrand: unique symbol;interface ScriptReturn<ret extends Record<string, ReturnValue>> { readonly [returnBrand]: ret;}ReturnValue is Expr | AnyTuple | AnyMutArray — an Expr, a Tuple handle, or a MutArray
handle. 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.