Errors and diagnostics
evs fails on three distinct timelines, with a different mechanism on each:
| Timeline | Mechanism | Where to look |
|---|---|---|
| Recording time | A subclass of EvsError is thrown while your builder callback runs |
This page |
| Compile time | compile() throws — EvsCompileError for whole-program limits and types too deeply nested to decode, EvsInternalError for verifier failures — and reports warnings via onDiagnostic |
This page |
| Run time | The script reverts on-chain — panics, the evs error ABI, or bubbled callee reverts | Compiled artifact, errors and debugging |
The policy is to validate at recording wherever the information already exists there — which is
almost everywhere — so most mistakes surface as an exception thrown synchronously from the
offending builder call inside your evscript callback, before any bytecode exists. The error’s
ordinary JavaScript stack trace therefore points at your line. Compile time is reserved for facts
only code generation knows: whole-program ones (code size, option gating) and the stack depth a
deeply nested type needs (see UNSUPPORTED_V0 below).
The EvsError hierarchy
Section titled “The EvsError hierarchy”Every evs failure is an instance of EvsError (which extends Error). What it carries:
import type { EvsError } from '@maxencerb/evs';
declare const error: EvsError;error.code; // EvsErrorCode — stable, machine-checkableerror.message; // user-vocabulary text with actionable guidanceerror.name; // the concrete class name, e.g. 'EvsTypeError'To find where a recording-time error came from, read its stack: the error is thrown while
your callback runs, so your call site is on it. Diagnostics are not attached to errors; they travel on a separate warning channel (below).
Five subclasses partition the failures:
| Class | Codes it uses | Thrown when |
|---|---|---|
EvsStagingError |
STAGING_MISUSE |
A staged Expr handle is used as a host value — the valueOf, Symbol.toPrimitive, toString, and toJSON traps all throw. See values and types |
EvsTypeError |
TYPE_MISMATCH, LITERAL_RANGE, CERTAIN_PANIC, UNSUPPORTED_V0, ABI_SHAPE, ERROR_DECL, ERROR_UNDECLARED |
Type and literal validation at recording; also malformed host inputs to compile, toViem, interpret, disassemble, explainRevert, deserializeIr |
EvsScopeError |
SCOPE_VIOLATION, FOREIGN_HANDLE, RECORDING_CLOSED |
A handle or cell is used outside the scope it belongs to |
EvsCompileError |
COMPILE_LIMIT, EVM_VERSION, UNSUPPORTED_V0 |
Whole-program limits and option gating at compile time; a struct/array type nested too deeply for the decoder’s 16-slot stack window |
EvsInternalError |
INTERNAL |
An internal invariant or verifier failed; the message always contains the phrase bug in evs, please report. Also semantically invalid IR handed to interpret() / compile() (see INTERNAL below) |
import { evscript, EvsTypeError, t } from '@maxencerb/evs';
try { evscript({ name: 'bad', args: [] }, (s) => { return s.return({ y: s.sub(s.lit(t.uint256, 1n), 2n) }); });} catch (e) { if (e instanceof EvsTypeError) { e.code; // 'CERTAIN_PANIC' — 1 − 2 underflows uint256, so it would always revert e.stack; // includes the line of the s.sub call }}EvsErrorCode — the complete union
Section titled “EvsErrorCode — the complete union”| Code | Class | Meaning | Timeline |
|---|---|---|---|
STAGING_MISUSE |
EvsStagingError |
A handle was coerced to a host value: x + 1, a template literal, JSON.stringify(x) |
Recording |
TYPE_MISMATCH |
EvsTypeError |
An operand or argument has the wrong type for the operation (including call args that fit no overload of an overloaded function, a struct literal key that names no member, and a Cell or MutArray where a tuple is expected); a handle method called without its handle — passed as a callback or stored and called bare, as in xs.map(x.add) or s.if(done, loop.break) (the message names the method and the arrow to write instead, (v) => x.add(v)); also structurally invalid host inputs to the public API entry points |
Recording / API entry |
LITERAL_RANGE |
EvsTypeError |
A literal fails validation for its type: numeric value out of range or not a safe integer, hex of the wrong length or odd length | Recording |
CERTAIN_PANIC |
EvsTypeError |
An all-literal operation provably panics at run time, so recording refuses it: literal overflow/underflow, a literal cast that does not fit, division by a literal zero, a literal s.newArray length of 2^32 or more |
Recording |
SCOPE_VIOLATION |
EvsScopeError |
A value or cell recorded inside an s.if/loop block is used after the block finished; an s.fn body captures enclosing values or cells; LoopCtl used outside its owning loop; s.return misplaced; an s.fn calls itself; a caught s.fn failure after its body defined a nested s.fn |
Recording |
FOREIGN_HANDLE |
EvsScopeError |
An Expr belonging to another script — or forged / from a duplicate evs install — was passed to this builder |
Recording |
RECORDING_CLOSED |
EvsScopeError |
A builder method was called after s.return sealed the script |
Recording |
UNSUPPORTED_V0 |
EvsTypeError (recording) / EvsCompileError (compile) |
A type or ABI feature outside the v0 surface. At recording: arrays nested more than four levels deep (uint256[][][][][] — the message says “nests arrays N levels deep — at most 4 levels are supported”, #4), or a static type of 2^32 bytes or more, alone or as an array’s element (uint256[65536][65536][]) — the message says “has an ABI static size of N bytes — at most 2^32 − 1 bytes are supported”. The same EvsTypeError comes from compile() and interpret() instead when only a raw ABI tuple’s total is too large, and for such a type anywhere in IR loaded with deserializeIr. At compile time, as EvsCompileError: a script argument, call output or zero value whose structs and arrays nest too deeply for the EVM’s 16-slot stack window (about a dozen nested structs, or four to five levels of structs inside arrays of structs — the message says “nests structs and arrays too deeply” and names the value; flatten the type, see values and types). Every other array shape is supported: fixed-size T[N], tuple[][], string[][]/bytes[][], uint256[][][], … |
Recording / Compile |
ABI_SHAPE |
EvsTypeError |
Malformed ABI material: invalid struct field names, a member name repeated within one tuple (viem decodes a named tuple into a name-keyed object and would drop a value — a hand-written ABI passed to s.read/s.call, or struct: true over outputs sharing a name), duplicate or invalid script arg names, duplicate or invalid return component names, an empty s.return({}) record, or a malformed ABI fragment passed to s.read; a functionName that selects no function in the verb’s mutability bucket (unknown name or signature, wrong bucket), or an overloaded one the args cannot settle — the args fit several overloads (settle it with a typed literal or a signature functionName), or t.fromOutputs given an overloaded bare name. (An invalid script name given to evscript() is caught at the entry point as TYPE_MISMATCH.) |
Recording |
ERROR_DECL |
EvsTypeError |
An invalid custom-error declaration: a bad t.error(...) name or params, a non-t.error entry in the def’s errors: [...], a reserved name (Error, Panic, EvsDecodeError, EvsInvalidCalldata, empty, unknown, _), a duplicate name, a 4-byte selector clash, or a script name equal to an error name in its ABI (a declared error, EvsDecodeError or EvsInvalidCalldata) |
Recording |
ERROR_UNDECLARED |
EvsTypeError |
s.throw names an error that is not in the script’s declared errors (or is declared under the same name with different params) |
Recording |
COMPILE_LIMIT |
EvsCompileError |
Runtime bytecode exceeds the EIP-170 limit of 24,576 bytes (the message includes a per-region breakdown); also interpret() exceeding its maxSteps budget (statements, loop iterations and zero-filled array elements) |
Compile / interpret |
EVM_VERSION |
EvsCompileError |
evmVersion is not 'paris', 'shanghai', or 'cancun' |
Compile |
INTERNAL |
EvsInternalError |
An evs invariant or bytecode verifier failed — a bug in evs, not in your script. The one exception is IR you load yourself: deserializeIr only checks its shape, and interpret() / compile() reject IR that is semantically invalid (a value used before it is defined, an unknown value id, an operand of the wrong type) with this error. Then the bug is in whatever produced that IR, even though the message still says to report it to evs |
Any |
Diagnostics — the compile-time warning channel
Section titled “Diagnostics — the compile-time warning channel”Warnings never throw and are never logged. They are delivered exclusively through the
onDiagnostic compile option; the default sink discards them, and the artifact is unaffected
either way.
import { compile, evscript, type EvsDiagnostic } from '@maxencerb/evs';
const script = evscript({ name: 'whoami', args: [] }, (s) => { return s.return({ me: s.env('caller') });});
const warnings: EvsDiagnostic[] = [];compile(script, { onDiagnostic: (d) => warnings.push(d) });
for (const w of warnings) { w.code; // 'ENV_FRAME_DEPENDENT' here — raised by the s.env('caller') call w.message; // names the construct that triggered it}The shapes:
import type { EvsDiagnostic } from '@maxencerb/evs';
declare const diagnostic: EvsDiagnostic;diagnostic.severity; // 'warning' — every diagnostic is a warning; hard failures throw insteaddiagnostic.code; // 'LOOP_ALLOCATION' | 'LARGE_FRAME' | 'ENV_FRAME_DEPENDENT' | 'DEPLOYLESS_RESULT_PREFIX' | 'DEPLOYLESS_RESULT_SIZE'diagnostic.message; // human-readable explanation with concrete guidancediagnostic.site; // number | undefined — the statement that raised it (absent on LARGE_FRAME and DEPLOYLESS_RESULT_SIZE)site is the id of the recorded statement behind a LOOP_ALLOCATION or ENV_FRAME_DEPENDENT
warning, and behind a DEPLOYLESS_RESULT_PREFIX one the statement that computes the first
returned value (absent when that value is a script argument): the site of that statement in
compiled.ir, with an entry (its kind and a short description) in compiled.sourceMap.sites. Every recorded statement has its own id, so two
look-alike statements — the same read written twice in one loop — produce two warnings with the
same message and different sites.
| Code | Triggers when | Why it matters |
|---|---|---|
LOOP_ALLOCATION |
An allocating statement sits inside a loop body or header: s.newArray or an array literal, s.tuple(...), a struct literal or a struct: true read (a flat-block allocation), an s.read/s.call (or try* form) with a string, bytes, array or tuple output (the returndata snapshot it aliases or decodes from), any s.simulate/s.trySimulate (the trampoline payload snapshot), s.encode/s.encodePacked and s.keccak256 over values that need encoding (a fresh bytes memref), a .slice(…) copy or a .asString() of a bytesN (a fresh string), a dynamic literal materialization, or a call to an s.fn whose body transitively allocates. A read whose outputs are all words (uint256, address, bool, …) allocates nothing and does not warn, unless encoding its arguments advances the free pointer: an argument that needs encoder state (a fixed-size array, an array of structs, strings, bytes or arrays, or a struct nested three or more dynamic levels deep) has it reserved there, and when any argument is a struct, a fixed-size array or an array of structs, strings, bytes or arrays, every literal string or bytes argument and every all-literal word-array argument (inline or s.lit, wherever it was recorded) is copied into a block allocated there |
evs never resets the free memory pointer, so memory — and memory-expansion gas — grows monotonically with every iteration for the lifetime of the call |
LARGE_FRAME |
The script’s static frame exceeds 32,768 bytes (0x8000); the message reports the byte and slot counts |
Memory-expansion gas grows quadratically; consider splitting the script |
ENV_FRAME_DEPENDENT |
The script records s.env('caller') or s.env('address'), reads its own balance with s.balance(s.env('address')) (one more warning), or sends a value (not a literal 0) with s.call / s.simulate or their try* forms (one warning per call site) |
The value depends on the execution frame, and the two toViem() modes run the script in different frames — see execution. A value is paid from the script’s own balance, which deployless mode cannot fund: there the CALL always fails before the target runs. Use toViem({ mode: 'stateOverride' }) with a balance in the script’s override entry, or sender mode — see sending ETH |
DEPLOYLESS_RESULT_PREFIX |
The result is ABI-static and its first word (directly or as the first member of a static struct or fixed array) is a bytesN, or a uint256/int256 that evs cannot bound away from the values that start with 0xEF (see which integers warn); the message names it, e.g. `pos.id` (bytes32), and site is the statement that computes the first returned value |
Deployless mode deposits the result as contract code, which must not start with 0xEF (EIP-3541): the call fails whenever that value’s first byte is 0xEF — about 1 time in 256 for a hash. Use toViem({ mode: 'stateOverride' }), or return an address, bool, narrower integer or any dynamic value first. For an amount or a count that warns anyway (a call output, say), acknowledge the warning by its site. See deployless limits |
DEPLOYLESS_RESULT_SIZE |
The encoded result is always over 24,576 bytes: exactly, for a static result, or with every dynamic part empty | Every deployless call fails (EIP-170 applies to the deposited result); use toViem({ mode: 'stateOverride' }) or return less |
Each LOOP_ALLOCATION message names the builder call that allocates — s.read(symbol),
s.read(pair) struct for a struct: true read, s.keccak256(…) encoded bytes,
s.newArray((uint256,address)), s.tuple(a, b), or the type of the literal it builds
((uint256,address) literal for a struct literal, uint256[] literal for an array literal
with a runtime element, a uint256[3] literal (materialized in memory) for one whose elements
are all constants) — and says what to do about it:
import { compile, evscript, t, type EvsDiagnostic } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const symbols = evscript({ name: 'symbols', args: [t.array(t.address)] }, (s, tokens) => { const total = s.let(t.uint256, 0n); s.forEach(tokens, (token) => { // a word output (decimals) allocates nothing; a string output keeps its returndata s.read({ address: token, abi: erc20Abi, functionName: 'decimals' }); const symbol = s.read({ address: token, abi: erc20Abi, functionName: 'symbol' }); total.set(total.get().add(symbol.length())); }); return s.return({ total: total.get() });});
const loopWarnings: EvsDiagnostic[] = [];const compiled = compile(symbols, { onDiagnostic: (d) => { if (d.code === 'LOOP_ALLOCATION') loopWarnings.push(d); },});// one warning, for the symbol read; its site resolves in the source mapconst site = compiled.sourceMap.sites.find((x) => x.id === loopWarnings[0]?.site);site?.detail; // 'decoding symbol() returndata'The warning reads:
s.read(symbol) (returndata snapshot) allocates memory on every loop iteration; evs never resets the free pointer, so memory grows monotonically for the lifetime of the call and each iteration pays more memory-expansion gas than the last. Hoist it out of the loop when it does not depend on the iteration; for a short, bounded loop the cost is small — filter this warning on its code and site (site ids are positional: re-check the filter after editing the script)The ENV_FRAME_DEPENDENT warning for the first example reads:
s.env('caller') is execution-frame-dependent: in the default deployless toViem() mode msg.sender is viem's internal wrapper contract — NOT the eth_call `account`; caller-relative reads require toViem({ mode: 'stateOverride' }) plus the `account` call parameterWhich integers warn
Section titled “Which integers warn”A uint256 starts with byte 0xEF only from 0xEF·2^248 (about 1.08·10^77), and an int256
only below -2^252 (about -7.2·10^75), so amounts and counts never do. DEPLOYLESS_RESULT_PREFIX
therefore always warns for a leading bytesN, but for a leading uint256 or int256 only when
evs cannot bound the value away from that range. It bounds the value’s bit width over the
whole script, loops and s.fn calls included:
- A returned literal is decided by its first byte: it warns only when that byte is
0xEF, and then every deployless call fails. - Bounded, no warning:
s.env('chainid'),s.env('blocknumber')ands.env('timestamp')(64 bits),.length()(64 bits),s.balance(128 bits) ands.codeSize(32 bits); a narrower integer widened withtoUint/toInt; checked arithmetic over bounded values (mulandmulDivadd the operands’ bits;divorshrby a literal,mod,addmodandmulmodshrink them;shrby any amount keeps them);bitAnd,bitOrandbitXoron unsigned values (bounded by their operands);selectover bounded branches. An addition chain, such as a loop counter or a running sum, adds at most 64 bits, because the gas of one call cannot run 2^64 additions. - Unbounded, warns: a script argument, a call output, an array element or a struct
member other than the first, and a fixed array or struct whose first member is written
with one of these; a
bytesNreinterpreted as an integer (s.keccak256(…).asUint());bitNot, a bit operation on signed values, a wrapping op that can wrap,shlby a runtime amount,powby a runtime exponent; anything computed from these, and a value that doubles in a loop.
The warning on a value evs cannot bound carries the site of the statement that computes it.
When that value is an amount or a count, which never gets that large, accept that one site:
import { compile, evscript, t, type EvsDiagnostic } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const total = evscript( { name: 'total', args: [t.address, t.array(t.address)] }, (s, token, holders) => { const sum = s.let(t.uint256, 0n); s.forEach(holders, (holder) => { const balance = s.read({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [holder] }); sum.set(sum.get().add(balance)); }); return s.return({ sum: sum.get(), holders: holders.length() }); },);
const warnings: EvsDiagnostic[] = [];const compiled = compile(total, { onDiagnostic: (d) => warnings.push(d) });const prefix = warnings.find((d) => d.code === 'DEPLOYLESS_RESULT_PREFIX');// the site of `sum.get()`: a sum of balances is an amount, so this one warning is safe to acceptcompiled.sourceMap.sites.find((x) => x.id === prefix?.site)?.detail; // 'cellget'Runtime panics
Section titled “Runtime panics”Checked operations revert with the standard Solidity Panic(uint256) encoding, byte-exact with
solc: the 4-byte selector 0x4e487b71 followed by one 32-byte code word — 36 bytes total. evs
emits exactly four panic codes:
| Code | Meaning (solc semantics) | Emitted by |
|---|---|---|
0x11 |
Arithmetic overflow or underflow | Checked add/sub/mul/pow; signed div (the minimum value divided by −1); mulDiv/mulDivRoundingUp when the quotient exceeds uint256; range-checked conversions (narrowing toUint/toInt, uintN to an intM with M ≤ N, any intN to a uint, asAddress on a uint256 or bytes32) |
0x12 |
Division or modulo by zero | div/mod/addmod/mulmod/mulDiv/mulDivRoundingUp with a runtime zero divisor, modulus or denominator |
0x32 |
Array index out of bounds | Expr.at(i) on arrays; MutArray get/set; byteAt(i) and slice(start, end) on string/bytes |
0x41 |
Allocation too large | s.newArray with a runtime length of 2^32 or more |
mod never raises 0x11 (unlike div, the remainder of the minimum value by −1 is 0), and
neither do free conversions: widenings, uintN to a wider intM, the asUint256 /
asBytes32 reinterprets, asUint160, asAddress on a uint160, and the same-width asUint /
asBytesN. Folded literals remove checks too: a division by a nonzero literal has
no zero check (for mulDiv, a nonzero literal denominator drops the 0x12 check but not the
0x11 one), a signed division by a literal other than −1 cannot overflow, and pow with a
literal exponent of 0 or 1 (or a literal base of 0, 1 or −1) is unchecked.
Callee reverts bubble through the script byte-exactly, so any other solc panic code can still
appear in a script’s revert payload — bubbled verbatim from a contract you called.
explainRevert decodes the full solc table (0x00 generic compiler panic, 0x01 failed
assert, 0x21 invalid enum conversion, 0x22 corrupted storage byte array, 0x31 pop on an
empty array, 0x51 call to a zero-initialized internal function) and, for the four codes above,
lists the candidate sites in your script: exactly the sites whose code can raise that panic
(each site’s panicCodes in the source map), with their operands, e.g.
checked div args.amount / args.shares (int256) — Panic 0x12/0x11. When no site can raise it,
the panic was bubbled from a callee, and the message names the strict call sites it could have
come through.
Beyond panics, a script can revert with the two evs-owned errors — EvsInvalidCalldata() and
EvsDecodeError(uint256 site) — or with a callee’s payload bubbled verbatim. The exact ABI
entries and selectors are documented in the artifact reference; the
end-to-end debugging workflow, including explainRevert and disassemble, is in
errors and debugging.