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';evscript()
Section titled “evscript()”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.namemust be a non-empty identifier (/^[A-Za-z_]\w*$/) other than__proto__.def.args, when present, must be a single declarator (a bareEvsTypeor anamedArg) or areadonlylist of them. AnamedArglabels its input in the generated ABI; a bare arg is auto-namedarg{i}(the names are positional labels; viem infersargspositionally regardless). Duplicate arg names are rejected (ABI_SHAPE).- Every arg type must be a valid
EvsType— a word/dynamic type, aTupleType(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 codeUNSUPPORTED_V0; a malformed type (uint256[0]) withTYPE_MISMATCH. def.errors, when present, must be at.errorvalue or a list of them, with no duplicate name or selector clash (ERROR_DECL).bodymust 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.
EvsScript
Section titled “EvsScript”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,EvsDecodeErrororEvsInvalidCalldata): viem would resolve the error entry instead of the function, soevscriptthrowsERROR_DECL.args— the normalizedreadonly ArgSpec[]of argument specs (the type param, not a runtime field): each{ name, type }carries the surfaced arg name (anamedArgname, or thearg{i}fallback for a bare arg) and its type.ir— the recorded program. Serialize it withserializeIror run it with theinterpretoracle without compiling; see testing scripts.errors— the declaredt.errorvalues, in declaration order ([]when the def has none).abi— the literal-typedScriptAbi: oneviewfunction, 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-standingcompile(script, options).
namedArg() and ArgSpec
Section titled “namedArg() and ArgSpec”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:
namemust match/^[A-Za-z_]\w*$/and not be__proto__; otherwiseEvsTypeErrorwith the call-site location.typeis anyEvsType— a word type,string,bytes, an array, or a compositet.struct/t.tuple(a named struct arg arrives as aTuplehandle, exactly like a bare one). Only top-level args are named as a whole; nested composite fields are named viat.struct. Arrays nested more than four levels deep (#4) throwEvsTypeErrorwith codeUNSUPPORTED_V0; malformed inputs throwTYPE_MISMATCH.s.fnparams 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 handleconst 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.
compile()
Section titled “compile()”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:
EvsCompileErrorwith codeEVM_VERSIONfor an unknownevmVersionstring.EvsCompileErrorwith codeCOMPILE_LIMITwhen 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, plustrampoline/codecswhen present).EvsTypeErrorwhen 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});CompileOptions
Section titled “CompileOptions”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 + evsPeepholeconst peepholeOnly = compile(total, { peephole: (nodes) => myHook(evsPeephole(nodes)) });