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
>(
def: { name: name; args?: args },
body: (s: ScriptBuilder, ...args: ArgHandles<NormalizeArgs<args>>) => ScriptReturn<ret>,
opts?: { locations?: boolean }, // default true: capture source locations
): EvsScript<name, NormalizeArgs<args>, ret>;

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.uint256args: [t.uint256]). A namedArg labels the arg in the viem args tuple; a bare arg keeps the positional arg0/arg1/… name. The const type parameters mean inline args need no as const.

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.

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

  • def.name must be a non-empty identifier (/^[A-Za-z_]\w*$/).
  • 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, an array (a scalar array, a one-level nested array T[][], or a tuple[]), or a TupleType (struct/tuple). Shapes outside the supported set — tuple[][], deeper-than-[][] nesting, fixed T[N] — throw with code UNSUPPORTED_V0.
  • body must be a function.

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

opts.locations (default true) controls source-location capture during recording. When enabled, every recorded statement stores a SourceLoc parsed from a stack trace, which powers error messages, sourceMap, explainRevert, and diagnostics. Pass { locations: false } to skip the per-statement stack capture (faster recording; locations in errors and the source map become null):

import { evscript, t } from '@maxencerb/evs';
const fast = evscript(
{ name: 'fast', args: [t.uint256] },
(s, x) => s.return({ x }),
{ locations: false },
);

compile has its own independent locations option (below) for the emitted source map.

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

import type {
ArgSpec,
CompiledEvsScript,
CompileOptions,
ReturnValue,
ScriptAbi,
ScriptIr,
} from '@maxencerb/evs';
interface EvsScript<
name extends string = string,
args extends readonly ArgSpec[] = readonly ArgSpec[],
ret extends Record<string, ReturnValue> = Record<string, ReturnValue>,
> {
readonly name: name;
readonly ir: ScriptIr; // frozen, JSON-serializable
readonly abi: ScriptAbi<name, args, ret>; // literal-typed value, exists pre-compile
compile(options?: CompileOptions): CompiledEvsScript<name, args, ret>; // sugar for compile()
}
  • name — the literal script name; becomes the ABI function name.
  • 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.
  • abi — the literal-typed ScriptAbi (one view function plus the two evs error entries). 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. namedArg validates at the call site and returns a frozen object:

  • name must match /^[A-Za-z_]\w*$/; 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. Deferred Solidity shapes (fixed-size T[N] arrays) throw EvsTypeError with code UNSUPPORTED_V0; malformed inputs throw TYPE_MISMATCH. Note s.fn params remain word/string-typed — a composite param is rejected when the fn is defined (see functions).
import {
function evscript<const name extends string, const args extends ArgsInput = readonly [], ret extends Record<string, ReturnValue> = Record<string, ReturnValue>>(def: {
name: name;
args?: args;
}, body: (s: ScriptBuilder, ...args: ArgHandles<NormalizeArgs<args>>) => ScriptReturn<ret>, opts?: {
locations?: boolean;
}): EvsScript<name, NormalizeArgs<args>, ret>
evscript
,
function namedArg<const name extends string, const type extends EvsType>(name: name, type: type): ArgSpec<name, type>

Names a top-level arg/param so the name surfaces in the resulting type (issue #9): in a script's args, the viem args tuple element is labeled ([token: …]); in an s.fn's params, the callback parameter is labeled ((token) => …). The type bound is

EvsType

— the full parameter-type vocabulary (widened by #25 from StringType): words, string/bytes, arrays, and composite t.struct/t.tuple descriptors (a named struct arg arrives as a Tuple handle, exactly like a bare one). Nested composite fields are named via t.struct and keep their behaviour; s.fn composite params remain a v0 deferral, rejected at record time. A bare (unnamed) top-level arg keeps the positional arg{i} fallback name.

namedArg
,
const t: TypeNamespace
t
} from '@maxencerb/evs';
const token =
namedArg<"token", "address">(name: "token", type: "address"): ArgSpec<"token", "address">

Names a top-level arg/param so the name surfaces in the resulting type (issue #9): in a script's args, the viem args tuple element is labeled ([token: …]); in an s.fn's params, the callback parameter is labeled ((token) => …). The type bound is

EvsType

— the full parameter-type vocabulary (widened by #25 from StringType): words, string/bytes, arrays, and composite t.struct/t.tuple descriptors (a named struct arg arrives as a Tuple handle, exactly like a bare one). Nested composite fields are named via t.struct and keep their behaviour; s.fn composite params remain a v0 deferral, rejected at record time. A bare (unnamed) top-level arg keeps the positional arg{i} fallback name.

namedArg
('token',
const t: TypeNamespace
t
.
address: "address"
address
);
const token: ArgSpec<"token", "address">
const
const fees: ArgSpec<"fees", "uint24[]">
fees
=
namedArg<"fees", "uint24[]">(name: "fees", type: "uint24[]"): ArgSpec<"fees", "uint24[]">

Names a top-level arg/param so the name surfaces in the resulting type (issue #9): in a script's args, the viem args tuple element is labeled ([token: …]); in an s.fn's params, the callback parameter is labeled ((token) => …). The type bound is

EvsType

— the full parameter-type vocabulary (widened by #25 from StringType): words, string/bytes, arrays, and composite t.struct/t.tuple descriptors (a named struct arg arrives as a Tuple handle, exactly like a bare one). Nested composite fields are named via t.struct and keep their behaviour; s.fn composite params remain a v0 deferral, rejected at record time. A bare (unnamed) top-level arg keeps the positional arg{i} fallback name.

namedArg
('fees',
const t: TypeNamespace
t
.
array<"uint24">(elem: "uint24"): "uint24[]" (+1 overload)
array
(
const t: TypeNamespace
t
.
uint24: "uint24"
uint24
)); // ArgSpec<'fees', 'uint24[]'>
const
const amount: ArgSpec<"amount", "uint128">
amount
=
namedArg<"amount", "uint128">(name: "amount", type: "uint128"): ArgSpec<"amount", "uint128">

Names a top-level arg/param so the name surfaces in the resulting type (issue #9): in a script's args, the viem args tuple element is labeled ([token: …]); in an s.fn's params, the callback parameter is labeled ((token) => …). The type bound is

EvsType

— the full parameter-type vocabulary (widened by #25 from StringType): words, string/bytes, arrays, and composite t.struct/t.tuple descriptors (a named struct arg arrives as a Tuple handle, exactly like a bare one). Nested composite fields are named via t.struct and keep their behaviour; s.fn composite params remain a v0 deferral, rejected at record time. A bare (unnamed) top-level arg keeps the positional arg{i} fallback name.

namedArg
('amount', 'uint128'); // raw type strings work everywhere t.* does
// composite types can be named too — the callback receives a Tuple handle
const
const MarketParams: StructTypeOf<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>
MarketParams
=
const t: TypeNamespace
t
.
struct<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>(spec: {
readonly loanToken: "address";
readonly lltv: "uint256";
}): StructTypeOf<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>
struct
({
loanToken: "address"
loanToken
:
const t: TypeNamespace
t
.
address: "address"
address
,
lltv: "uint256"
lltv
:
const t: TypeNamespace
t
.
uint256: "uint256"
uint256
});
const
const position: EvsScript<"position", readonly [ArgSpec<"marketParams", StructTypeOf<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>>], {
readonly loan: Expr<"address">;
}>
position
=
evscript<"position", readonly [ArgSpec<"marketParams", StructTypeOf<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>>], {
readonly loan: Expr<"address">;
}>(def: {
name: "position";
args?: readonly [ArgSpec<"marketParams", StructTypeOf<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>>] | undefined;
}, body: (s: ScriptBuilder, marketParams: Tuple<StructTypeOf<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>>) => ScriptReturn<{
readonly loan: Expr<"address">;
}>, opts?: {
locations?: boolean;
}): EvsScript<...>
evscript
(
{
name: "position"
name
: 'position',
args?: readonly [ArgSpec<"marketParams", StructTypeOf<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>>] | undefined
args
: [
namedArg<"marketParams", StructTypeOf<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>>(name: "marketParams", type: StructTypeOf<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>): ArgSpec<"marketParams", StructTypeOf<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>>

Names a top-level arg/param so the name surfaces in the resulting type (issue #9): in a script's args, the viem args tuple element is labeled ([token: …]); in an s.fn's params, the callback parameter is labeled ((token) => …). The type bound is

EvsType

— the full parameter-type vocabulary (widened by #25 from StringType): words, string/bytes, arrays, and composite t.struct/t.tuple descriptors (a named struct arg arrives as a Tuple handle, exactly like a bare one). Nested composite fields are named via t.struct and keep their behaviour; s.fn composite params remain a v0 deferral, rejected at record time. A bare (unnamed) top-level arg keeps the positional arg{i} fallback name.

namedArg
('marketParams',
const MarketParams: StructTypeOf<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>
MarketParams
)] },
(
s: ScriptBuilder
s
,
marketParams: Tuple<StructTypeOf<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>>
marketParams
) =>
s: ScriptBuilder
s
.
ScriptBuilder.return<{
readonly loan: Expr<"address">;
}>(values: {
readonly loan: Expr<"address">;
}): ScriptReturn<{
readonly loan: Expr<"address">;
}>
return
({
loan: Expr<"address">
loan
:
marketParams: Tuple<StructTypeOf<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>>
marketParams
.
loanToken: Field<"address">
loanToken
.
Field<"address">.get(): Expr<"address">
get
() }),
);
void [
const token: ArgSpec<"token", "address">
token
,
const fees: ArgSpec<"fees", "uint24[]">
fees
,
const amount: ArgSpec<"amount", "uint128">
amount
,
const position: EvsScript<"position", readonly [ArgSpec<"marketParams", StructTypeOf<{
readonly loanToken: "address";
readonly lltv: "uint256";
}>>], {
readonly loan: Expr<"address">;
}>
position
];

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

import {
function evscript<const name extends string, const args extends ArgsInput = readonly [], ret extends Record<string, ReturnValue> = Record<string, ReturnValue>>(def: {
name: name;
args?: args;
}, body: (s: ScriptBuilder, ...args: ArgHandles<NormalizeArgs<args>>) => ScriptReturn<ret>, opts?: {
locations?: boolean;
}): EvsScript<name, NormalizeArgs<args>, ret>
evscript
,
function namedArg<const name extends string, const type extends EvsType>(name: name, type: type): ArgSpec<name, type>

Names a top-level arg/param so the name surfaces in the resulting type (issue #9): in a script's args, the viem args tuple element is labeled ([token: …]); in an s.fn's params, the callback parameter is labeled ((token) => …). The type bound is

EvsType

— the full parameter-type vocabulary (widened by #25 from StringType): words, string/bytes, arrays, and composite t.struct/t.tuple descriptors (a named struct arg arrives as a Tuple handle, exactly like a bare one). Nested composite fields are named via t.struct and keep their behaviour; s.fn composite params remain a v0 deferral, rejected at record time. A bare (unnamed) top-level arg keeps the positional arg{i} fallback name.

namedArg
,
const t: TypeNamespace
t
} from '@maxencerb/evs';
import type {
type ReadContractParameters<abi extends Abi | readonly unknown[] = Abi, functionName extends ContractFunctionName<abi, "pure" | "view"> = ContractFunctionName<abi, "pure" | "view">, args extends ContractFunctionArgs<abi, "pure" | "view", functionName> = ContractFunctionArgs<...>> = {
authorizationList?: AuthorizationList<number, boolean> | undefined;
account?: `0x${string}` | Account | undefined;
blockHash?: `0x${string}` | undefined;
blockNumber?: bigint | undefined | undefined;
blockOverrides?: BlockOverrides<bigint, number> | undefined;
blockTag?: BlockTag | undefined;
factory?: `0x${string}` | undefined;
factoryData?: `0x${string}` | undefined;
requireCanonical?: boolean | undefined;
stateOverride?: StateOverride | undefined;
} & ContractFunctionParameters<abi, "pure" | "view", functionName, args, boolean>
ReadContractParameters
} from 'viem';
const
const balance: EvsScript<"balance", readonly [ArgSpec<"token", "address">, ArgSpec<"holder", "address">], {
readonly token: Expr<"address">;
readonly holder: Expr<"address">;
}>
balance
=
evscript<"balance", readonly [ArgSpec<"token", "address">, ArgSpec<"holder", "address">], {
readonly token: Expr<"address">;
readonly holder: Expr<"address">;
}>(def: {
name: "balance";
args?: readonly [ArgSpec<"token", "address">, ArgSpec<"holder", "address">] | undefined;
}, body: (s: ScriptBuilder, token: Expr<"address">, holder: Expr<"address">) => ScriptReturn<{
readonly token: Expr<"address">;
readonly holder: Expr<"address">;
}>, opts?: {
locations?: boolean;
}): EvsScript<...>
evscript
(
{
name: "balance"
name
: 'balance',
args?: readonly [ArgSpec<"token", "address">, ArgSpec<"holder", "address">] | undefined
args
: [
namedArg<"token", "address">(name: "token", type: "address"): ArgSpec<"token", "address">

Names a top-level arg/param so the name surfaces in the resulting type (issue #9): in a script's args, the viem args tuple element is labeled ([token: …]); in an s.fn's params, the callback parameter is labeled ((token) => …). The type bound is

EvsType

— the full parameter-type vocabulary (widened by #25 from StringType): words, string/bytes, arrays, and composite t.struct/t.tuple descriptors (a named struct arg arrives as a Tuple handle, exactly like a bare one). Nested composite fields are named via t.struct and keep their behaviour; s.fn composite params remain a v0 deferral, rejected at record time. A bare (unnamed) top-level arg keeps the positional arg{i} fallback name.

namedArg
('token',
const t: TypeNamespace
t
.
address: "address"
address
),
namedArg<"holder", "address">(name: "holder", type: "address"): ArgSpec<"holder", "address">

Names a top-level arg/param so the name surfaces in the resulting type (issue #9): in a script's args, the viem args tuple element is labeled ([token: …]); in an s.fn's params, the callback parameter is labeled ((token) => …). The type bound is

EvsType

— the full parameter-type vocabulary (widened by #25 from StringType): words, string/bytes, arrays, and composite t.struct/t.tuple descriptors (a named struct arg arrives as a Tuple handle, exactly like a bare one). Nested composite fields are named via t.struct and keep their behaviour; s.fn composite params remain a v0 deferral, rejected at record time. A bare (unnamed) top-level arg keeps the positional arg{i} fallback name.

namedArg
('holder',
const t: TypeNamespace
t
.
address: "address"
address
)] },
(
s: ScriptBuilder
s
,
token: Expr<"address">
token
,
holder: Expr<"address">
holder
) =>
s: ScriptBuilder
s
.
ScriptBuilder.return<{
readonly token: Expr<"address">;
readonly holder: Expr<"address">;
}>(values: {
readonly token: Expr<"address">;
readonly holder: Expr<"address">;
}): ScriptReturn<{
readonly token: Expr<"address">;
readonly holder: Expr<"address">;
}>
return
({
token: Expr<"address">
token
,
holder: Expr<"address">
holder
}), // body params are labeled `token`, `holder`
);
// viem labels the call args with your names — `token`/`holder`, not `arg0`/`arg1`:
type Args =
type ReadContractParameters<abi extends Abi | readonly unknown[] = Abi, functionName extends ContractFunctionName<abi, "pure" | "view"> = ContractFunctionName<abi, "pure" | "view">, args extends ContractFunctionArgs<abi, "pure" | "view", functionName> = ContractFunctionArgs<...>> = {
authorizationList?: AuthorizationList<number, boolean> | undefined;
account?: `0x${string}` | Account | undefined;
blockHash?: `0x${string}` | undefined;
blockNumber?: bigint | undefined | undefined;
blockOverrides?: BlockOverrides<bigint, number> | undefined;
blockTag?: BlockTag | undefined;
factory?: `0x${string}` | undefined;
factoryData?: `0x${string}` | undefined;
requireCanonical?: boolean | undefined;
stateOverride?: StateOverride | undefined;
} & ContractFunctionParameters<abi, "pure" | "view", functionName, args, boolean>
ReadContractParameters
<typeof
const balance: EvsScript<"balance", readonly [ArgSpec<"token", "address">, ArgSpec<"holder", "address">], {
readonly token: Expr<"address">;
readonly holder: Expr<"address">;
}>
balance
.
EvsScript<"balance", readonly [ArgSpec<"token", "address">, ArgSpec<"holder", "address">], { readonly token: Expr<"address">; readonly holder: Expr<"address">; }>.abi: ScriptAbi<"balance", readonly [ArgSpec<"token", "address">, ArgSpec<"holder", "address">], {
readonly token: Expr<"address">;
readonly holder: Expr<"address">;
}>
abi
, 'balance'>['args'];
type Args = readonly [token: `0x${string}`, holder: `0x${string}`]
void
const balance: EvsScript<"balance", readonly [ArgSpec<"token", "address">, ArgSpec<"holder", "address">], {
readonly token: Expr<"address">;
readonly holder: Expr<"address">;
}>
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, 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>
>
? CompiledEvsScript<n, a, r>
: never;

compile turns a recorded script into a CompiledEvsScript. The pipeline: validate the IR, lower to assembler nodes, run 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).
  • 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'
peephole?: (nodes: readonly AsmNode[]) => AsmNode[];
onDiagnostic?: (d: EvsDiagnostic) => void;
locations?: boolean;
}
Option Default Effect
evmVersion 'cancun' Target opcode set ('paris', 'shanghai', or 'cancun'). Anything else throws EvsCompileError (EVM_VERSION). See EVM targets.
peephole identity Optimizer seam: transforms the assembler node stream before layout. No optimizer ships in v0. The mandatory verifiers run on the hook’s output.
onDiagnostic no-op Receives every compile-time warning (LOOP_ALLOCATION, LARGE_FRAME, ENV_FRAME_DEPENDENT). evs never logs; without a callback, diagnostics are silently dropped.
locations true Whether source locations are carried into the emitted source map.

The compiled artifact exposes the fully resolved options as options: Readonly<Required<CompileOptions>> — defaults filled in. Diagnostic codes and their meaning are documented in diagnostics.