Skip to content

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.

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 evscript args (issue #9): a bare t.* type, a single namedArg(name, type), or a readonly list mixing named/bare. A namedArg labels the callback parameter ((token) => …); a bare param keeps the positional arg{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 composite t.struct/t.tuple — exactly the vocabulary script args accept. The body receives the same handles a script body would: a struct/tuple param is a Tuple handle (named field access), everything else an Expr. Dynamic and composite values pass as memref pointer words (no copy).
  • Returns can be a single Expr, a single Tuple handle (a struct/composite result — the call site then gets a Tuple with named field access, like s.read), a single MutArray handle (an array result — the call site gets an array Expr), a readonly tuple of any of those (use as const, as above), or void. (Composite/array returns were widened in issue #5; a bare Tuple/MutArray body 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.

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.

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.

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.