User functions
s.fn(name, params, body) declares a typed subroutine inside a script. The body records once,
at the point of definition; each call afterwards records a single statement and compiles to a
jump into one shared copy of the code.
Declaring and calling
Section titled “Declaring and calling”import { namedArg, evscript, t } from '@maxencerb/evs';
const clamp = evscript( { name: 'clamp', args: [t.uint256, t.uint256, t.uint256] }, (s, x, lo, hi) => { const minMax = s.fn('minMax', [namedArg('a', t.uint256), namedArg('b', t.uint256)], (a, b) => { const aFirst = a.lt(b); return [s.select(aFirst, a, b), s.select(aFirst, b, a)] as const; }); const [low, high] = minMax(lo, hi); // fresh Exprs per call const clamped = s.select(x.lt(low), low, s.select(x.gt(high), high, x)); return s.return({ clamped }); },);- Parameters accept the same shorthand as
evscriptargs (issue #9): a baret.*type, a singlenamedArg(name, type), or areadonlylist mixing named/bare. AnamedArglabels the callback parameter ((token) => …); a bare param keeps the positionalarg{i}name. These editor labels need viem ≥ 2.43.0; an older viem gives the same parameter types, unlabeled. A param may be any evs type — a word,string/bytes, an array, or a compositet.struct/t.tuple— exactly the vocabulary script args accept. The body receives the same handles a script body would: a struct/tuple param is aTuplehandle (named field access), everything else anExpr. Dynamic and composite values pass as memref pointer words (no copy). - Returns can be a single
Expr, a singleTuplehandle (a struct/composite result — the call site then gets aTuplewith named field access, likes.read), a singleMutArrayhandle (an array result — the call site gets an arrayExpr), a readonly tuple of any of those (useas const, as above), orvoid. (Composite/array returns were widened in issue #5; a bareTuple/MutArraybody return is byte-identical IR to wrapping it in.expr().) - Call sites accept literals anywhere a parameter type allows them —
minMax(lo, 100n)works, with the usual literal coercion rules.
Each call returns fresh handles: two calls never alias, even with identical arguments —
minMax(a, b) twice records two executions and yields two independent result tuples.
Struct params
Section titled “Struct params”A t.struct / t.tuple param binds like a struct script arg: inside the body it is a Tuple
handle with member-typed .field.get() / .set() access, and the call site accepts a Tuple
handle (a script arg, an s.tuple(...), another fn’s struct result) or an object literal
naming every member, whose members may be constants or runtime values:
import { namedArg, evscript, t } from '@maxencerb/evs';
const Pair = t.struct({ token: t.address, fee: t.uint24 });
const pairFees = evscript( { name: 'pairFees', args: [namedArg('pair', Pair), t.uint24] }, (s, pair, fee) => { // `p` is a Tuple handle inside the body — the same handle the script arg `pair` is const feeOf = s.fn('feeOf', [namedArg('p', Pair)], (p) => p.fee.get()); const built = s.tuple(Pair, { token: '0x0000000000000000000000000000000000000002', fee }); return s.return({ fromArg: feeOf(pair), // the script-arg struct passes straight through, by reference fromBuilt: feeOf(built), fromLiteral: feeOf({ token: '0x0000000000000000000000000000000000000001', fee: 500 }), }); },);The param is passed by reference: the caller stores the struct’s memref pointer into the
callee’s param slot, so a .set() inside the body is visible to the caller afterwards (as with
any Tuple alias) and no bytes are copied. Member types are checked structurally at recording —
a struct with the same layout but a different member name is a TYPE_MISMATCH, even though the
erased Tuple brand lets it typecheck. Composite arrays (t.array(Pair)) arrive as an
Expr with .length() / .at(i), again like a script arg.
Fn bodies are isolated — no capture
Section titled “Fn bodies are isolated — no capture”The body runs once, at definition, in its own scope. It can use the builder freely (s.read,
s.if, cells declared inside, nested loops), but it cannot touch Exprs or Cells recorded
in the enclosing script:
const portfolio = evscript({ name: 'portfolio', args: [t.address] }, (s, owner) => { const bad = s.fn('bad', [namedArg('token', t.address)], (token) => // `owner` belongs to the enclosing script, not to the fn body: s.read({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [owner] }), ); // EvsScopeError (SCOPE_VIOLATION): s.fn("bad") bodies cannot capture values from // the enclosing script — pass them in as fn params instead return s.return({ ok: bad('0x0000000000000000000000000000000000000000') });});This typechecks but throws EvsScopeError at recording, citing both the captured value’s
location and the fn definition. The fix is always the same: add a parameter. Recursion is
unconstructible by design — the callable handle does not exist yet inside its own body.
When s.fn pays off
Section titled “When s.fn pays off”A plain JS helper function that calls builder methods is inlining: it records its
statements again at every call site, duplicating bytecode per use (and unlike s.fn, it may
freely capture outer handles — for one or two uses that is often the simpler tool). s.fn
instead compiles to a single JUMPDEST subroutine emitted once regardless of call count,
at the cost of a small per-call jump-and-return overhead.
Reach for s.fn when a multi-statement block (typically containing calls) runs at many sites
or inside a loop over runtime data — the size saving compounds against the EIP-170 bytecode
cap (see EVM targets). Defined-but-uncalled fns are dropped from the
output entirely.
Full example: a balance subroutine over a loop
Section titled “Full example: a balance subroutine over a loop”import { namedArg, evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const portfolio = evscript( { name: 'portfolio', args: [t.address, t.array(t.address)] }, (s, owner, tokens) => { // everything the body needs comes in as params — fn bodies cannot capture const balOf = s.fn( 'balOf', [namedArg('token', t.address), namedArg('who', t.address)], (token, who) => s.read({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [who] }), ); const n = tokens.length(); const out = s.newArray(t.uint256, n); s.for({ type: t.uint256, from: 0n, until: n }, (i) => { out.set(i, balOf(tokens.at(i), owner)); // one fncall stmt per use }); return s.return({ balances: out.expr() }); },);The loop machinery (s.for, s.newArray, cells) is covered in
control flow; the sub-call typing in calls. A
revert-tolerant variant of this exact shape — s.tryRead so one bad token cannot kill the
batch — is the batch token balances example, and more s.fn
patterns live in patterns. Frozen signatures: the
ScriptBuilder reference.