Skip to content

evscript and compile

This page is the signature-level reference for the script entry point and the compiler entry point. For the narrative introduction, see writing scripts; for the compiled artifact returned by compile, see the artifact reference.

import { namedArg, compile, evscript, t } from '@maxencerb/evs';
declare function evscript<
const name extends string,
const args extends ArgsInput, // ArgInput | readonly ArgInput[]; ArgInput = EvsType | ArgSpec
ret extends Record<string, ReturnValue>, // each value: Expr | Tuple | MutArray
const errs extends ErrorsInput, // EvsErrorType | readonly EvsErrorType[] (t.error values)
>(
def: { name: name; args?: args; errors?: errs },
body: (
s: ScriptBuilder<NormalizeErrors<errs>>,
...args: ArgHandles<NormalizeArgs<args>>
) => ScriptReturn<ret>,
): EvsScript<name, NormalizeArgs<args>, ret, NormalizeErrors<errs>>;

evscript records a script: it runs body exactly once, at recording time, against a ScriptBuilder and captures every builder call into a frozen IR. The callback must return the value produced by s.return(...) — returning anything else (or not calling s.return at all) throws EvsTypeError.

Args are declared as t.* types or namedArg(name, type) wrappers — a single declarator or a readonly list — and arrive as positional parameters after s. A scalar arg arrives as an Expr, a TupleType arg as a Tuple handle. args is optional; a zero-arg script omits it. A lone declarator is sugar for a one-element list (args: t.uint256 ≡ args: [t.uint256]). A namedArg labels the arg in the viem args tuple; a bare arg keeps the positional arg0/arg1/… name. The same names label the body’s parameters in your editor with viem ≥ 2.43.0 (an older viem gives the same parameter types, unlabeled). The const type parameters mean inline args need no as const.

errors (optional) declares the script’s custom errors: a single t.error(...) value or a readonly list of them. Only declared errors can be thrown with s.throw, and each one is appended to the script’s ABI.

import { evscript, t } from '@maxencerb/evs';
const double = evscript(
{ name: 'double', args: [t.uint256] },
(s, x) => s.return({ doubled: x.mul(2n) }),
);
const compiled = double.compile(); // sugar for compile(double)

The relevant type helpers (all exported):

Type Meaning
ArgInput EvsType | ArgSpec — one declarator: a bare type or a namedArg.
ArgsInput ArgInput | readonly ArgInput[] — the accepted args input.
NormalizeArgs<a> Normalizes to readonly ArgSpec[] (a lone declarator → a one-element list).
ArgHandle<t> t extends TupleType ? Tuple<t> : Expr<t> — one arg’s body handle.
ArgHandles<specs> The positional handle tuple spread into the body after s, labeled by arg name.
ErrorsInput EvsErrorType | readonly EvsErrorType[] — the accepted errors input.
NormalizeErrors<e> Normalizes to readonly EvsErrorType[] (a lone declaration → a one-element list).

Recording-time validation (each violation throws EvsTypeError with the call-site location):

  • def.name must be a non-empty identifier (/^[A-Za-z_]\w*$/) other than __proto__.
  • def.args, when present, must be a single declarator (a bare EvsType or a namedArg) or a readonly list of them. A namedArg labels its input in the generated ABI; a bare arg is auto-named arg{i} (the names are positional labels; viem infers args positionally regardless). Duplicate arg names are rejected (ABI_SHAPE).
  • Every arg type must be a valid EvsType — a word/dynamic type, a TupleType (struct/tuple), or an array of any of those, dynamic or fixed-size (T[], T[N], tuple[], tuple[][], uint256[][][], string[2], …). An array nested more than four levels deep throws with code UNSUPPORTED_V0; a malformed type (uint256[0]) with TYPE_MISMATCH.
  • def.errors, when present, must be a t.error value or a list of them, with no duplicate name or selector clash (ERROR_DECL).
  • body must be a function.

Everything the builder itself can throw during recording is catalogued in diagnostics.

Builder errors are thrown synchronously while body runs, so a recording-time error’s stack trace includes the line of your script that caused it.

The value returned by evscript. It is frozen; ir is deep-frozen.

import type {
ArgSpec,
CompiledEvsScript,
CompileOptions,
ReturnValue,
ScriptAbi,
EvsErrorType,
ScriptIr,
} from '@maxencerb/evs';
interface EvsScript<
name extends string = string,
args extends readonly ArgSpec[] = readonly ArgSpec[],
ret extends Record<string, ReturnValue> = Record<string, ReturnValue>,
errs extends readonly EvsErrorType[] = readonly EvsErrorType[],
> {
readonly name: name;
readonly ir: ScriptIr; // frozen, JSON-serializable
readonly abi: ScriptAbi<name, args, ret, errs>; // literal-typed value, exists pre-compile
readonly errors: errs; // the declared t.error values (frozen; [] when none)
compile(options?: CompileOptions): CompiledEvsScript<name, args, ret, errs>; // sugar for compile()
}
  • name — the literal script name; becomes the ABI function name. It may not equal an error name in the same ABI (a declared error, EvsDecodeError or EvsInvalidCalldata): viem would resolve the error entry instead of the function, so evscript throws ERROR_DECL.
  • args — the normalized readonly ArgSpec[] of argument specs (the type param, not a runtime field): each { name, type } carries the surfaced arg name (a namedArg name, or the arg{i} fallback for a bare arg) and its type.
  • ir — the recorded program. Serialize it with serializeIr or run it with the interpret oracle without compiling; see testing scripts.
  • errors — the declared t.error values, in declaration order ([] when the def has none).
  • abi — the literal-typed ScriptAbi: one view function, the two evs error entries, then one entry per declared error. It exists before you compile, so viem type inference works without any artifact. See the artifact reference for its shape.
  • compile(options?) — identical to the free-standing compile(script, options).
import type { ArgType, EvsType } from '@maxencerb/evs';
interface ArgSpec<name extends string = string, type extends ArgType = ArgType> {
readonly name: name;
readonly type: type;
}
declare function namedArg<const name extends string, const type extends EvsType>(
name: name,
type: type,
): ArgSpec<name, type>;

namedArg names one top-level arg/param so the name surfaces in the resulting type — usable consistently in both a script’s args and an s.fn’s params. Anywhere a bare t.* type is accepted, a namedArg(...) is too. A named script arg surfaces as a labeled element in the viem args tuple ([token: 0x…] instead of [arg0: 0x…]); a named s.fn param surfaces as a labeled callback parameter ((token) => …). A bare (unnamed) arg keeps the positional arg0/arg1/… fallback name. An ABI parameter taken from a function’s inputs, scalar or struct, is named by its own name the same way (see ABI parameters as args). namedArg validates at the call site and returns a frozen object:

  • name must match /^[A-Za-z_]\w*$/ and not be __proto__; otherwise EvsTypeError with the call-site location.
  • type is any EvsType — a word type, string, bytes, an array, or a composite t.struct/t.tuple (a named struct arg arrives as a Tuple handle, exactly like a bare one). Only top-level args are named as a whole; nested composite fields are named via t.struct. Arrays nested more than four levels deep (#4) throw EvsTypeError with code UNSUPPORTED_V0; malformed inputs throw TYPE_MISMATCH. s.fn params take any evs type too, struct/tuple included (see functions).
import { evscript, namedArg, t } from '@maxencerb/evs';
const token = namedArg('token', t.address);
// ^? ArgSpec<'token', 'address'>
const fees = namedArg('fees', t.array(t.uint24)); // ArgSpec<'fees', 'uint24[]'>
const amount = namedArg('amount', 'uint128'); // raw type strings work everywhere t.* does
// composite types can be named too — the callback receives a Tuple handle
const MarketParams = t.struct({ loanToken: t.address, lltv: t.uint256 });
const position = evscript(
{ name: 'position', args: [namedArg('marketParams', MarketParams)] },
(s, marketParams) => s.return({ loan: marketParams.loanToken.get() }),
);
void [token, fees, amount, position];

Used in a script’s args, the name flows into the ABI input and surfaces as the viem args label — see the inferred Args type below (a bare arg would read arg0/arg1 instead):

import { evscript, namedArg, t } from '@maxencerb/evs';
import type { ReadContractParameters } from 'viem';
const balance = evscript(
{ name: 'balance', args: [namedArg('token', t.address), namedArg('holder', t.address)] },
(s, token, holder) => s.return({ token, holder }), // body params are labeled `token`, `holder`
);
// viem labels the call args with your names — `token`/`holder`, not `arg0`/`arg1`:
type Args = ReadContractParameters<typeof balance.abi, 'balance'>['args'];
// ^? readonly [token: `0x${string}`, holder: `0x${string}`]
void balance;

For script arguments, declaration order is still load-bearing: the args list order is the type-level order, the runtime encode order, and the ABI inputs order — call sites stay viem-native positional (args: [pool, fee]). The full type vocabulary lives in the types reference.

import type {
ArgSpec,
CompiledEvsScript,
CompileOptions,
EvsErrorType,
EvsScript,
ReturnValue,
ScriptIr,
} from '@maxencerb/evs';
declare function compile<
s extends { readonly name: string; readonly ir: ScriptIr; readonly abi: readonly unknown[] },
>(
script: s,
options?: CompileOptions,
): s extends EvsScript<
infer n extends string,
infer a extends readonly ArgSpec[],
infer r extends Record<string, ReturnValue>,
infer e extends readonly EvsErrorType[]
>
? CompiledEvsScript<n, a, r, e>
: never;

compile turns a recorded script into a CompiledEvsScript. The pipeline: validate the IR, lower to assembler nodes (over a liveness-packed frame when optimize is on), run the built-in peephole pass when optimize is on and then the peephole hook, assemble with the mandatory verifiers, enforce the EIP-170 size cap, and merge site information into the source map. Stage-by-stage detail is in how it works.

The constraint is structural ({ name, ir, abi }) rather than s extends EvsScript — a deliberate, recorded deviation so that every concrete script is assignable; the result type is exactly CompiledEvsScript with the script’s literal type parameters preserved.

Failure modes:

  • EvsCompileError with code EVM_VERSION for an unknown evmVersion string.
  • EvsCompileError with code COMPILE_LIMIT when the runtime bytecode exceeds the EIP-170 limit of 24,576 bytes; the message includes a per-region size breakdown (dispatcher, body, fns, tails, data segments, plus trampoline / codecs when present).
  • EvsTypeError when the value passed is not a script object.
import { compile, evscript } from '@maxencerb/evs';
import type { EvsDiagnostic } from '@maxencerb/evs';
const whoami = evscript({ name: 'whoami', args: [] }, (s) => s.return({ me: s.env('caller') }));
const warnings: EvsDiagnostic[] = [];
const compiled = compile(whoami, {
evmVersion: 'paris', // pre-Shanghai target
onDiagnostic: (d) => warnings.push(d), // ENV_FRAME_DEPENDENT lands here
});
import type { AsmNode, EvmVersion, EvsDiagnostic } from '@maxencerb/evs';
interface CompileOptions {
evmVersion?: EvmVersion; // 'paris' | 'shanghai' | 'cancun'
optimize?: boolean;
peephole?: (nodes: readonly AsmNode[]) => AsmNode[];
onDiagnostic?: (d: EvsDiagnostic) => void;
}
Option Default Effect
evmVersion 'cancun' Target opcode set ('paris', 'shanghai', or 'cancun'). Anything else throws EvsCompileError (EVM_VERSION). See EVM targets.
optimize false Enables the built-in optimizer passes: the liveness-based frame allocator (#41 — dead values’ slots are reused; args, cells and fn params stay dedicated) and the conservative asm-level peephole pass evsPeephole (#39: store-then-reload folding, constant folding, stack identities). false is byte-identical to the plain lowering.
peephole identity Optimizer seam: a user hook over the assembler node stream before layout. With optimize: true it runs after the built-in pass and sees its output. The mandatory verifiers run on the final stream either way. Immediates must stay in push / pushBytes / pushLabel nodes: an op node naming PUSH1–PUSH32 throws EvsInternalError (a bare PUSH0 is fine, it has no immediate), and so does a push node whose value is not a bigint.
onDiagnostic no-op Receives every compile-time warning (LOOP_ALLOCATION, LARGE_FRAME, ENV_FRAME_DEPENDENT, DEPLOYLESS_RESULT_PREFIX, DEPLOYLESS_RESULT_SIZE — see diagnostics). evs never logs; without a callback, diagnostics are silently dropped.

The compiled artifact exposes the fully resolved options as options: Readonly<Required<CompileOptions>> — defaults filled in (options.peephole is always your own hook, never the built-in composition). Diagnostic codes and their meaning are documented in diagnostics.

optimize: true enables both built-in passes at once: the frame allocator runs inside codegen (it is not a node-stream hook), and the peephole pass is evsPeephole, exported so it can be composed with your own hook by hand or run standalone over a node stream — that composition keeps the default one-slot-per-value frame:

import { compile, evscript, evsPeephole, t } from '@maxencerb/evs';
import type { AsmNode } from '@maxencerb/evs';
const total = evscript({ name: 'total', args: [t.uint256, t.uint256] }, (s, a, b) =>
s.return({ total: s.add(a, b) }),
);
const myHook = (nodes: readonly AsmNode[]): AsmNode[] => [...nodes];
const optimized = compile(total, { optimize: true }); // packed frame + evsPeephole
const peepholeOnly = compile(total, { peephole: (nodes) => myHook(evsPeephole(nodes)) });