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. A param may be any string-encoded type — a word, dynamic, or string array; composite/tuple params are a deliberate v0 narrowing, rejected when the fn is defined (UNSUPPORTED_V0) whether declared bare or vianamedArg(which itself accepts struct types — for script args). Dynamic types pass as memref pointer words. The body receives them asExprs. - 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.
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.