Errors and debugging
An evs script can fail on three distinct timelines, and each one fails differently:
| Timeline | What you get | Typical causes |
|---|---|---|
| Recording — the builder callback | EvsTypeError / EvsStagingError / EvsScopeError, thrown at the offending line |
out-of-range literals, type mismatches, using an Expr as a plain JS value, captures in s.fn |
Compile — compile() |
EvsCompileError thrown; EvsDiagnostic warnings via onDiagnostic |
EIP-170 size overflow, unknown evmVersion; warnings like LOOP_ALLOCATION |
Run time — the eth_call |
revert payloads: Panic(uint256), the evs error ABI, bubbled callee reverts |
overflow, out-of-bounds indexing, malformed returndata, a callee reverting |
Every TS-side error extends EvsError, which carries a machine-readable code
(EvsErrorCode) and a message. The full code list lives in the
diagnostics reference.
Recording-time errors
Section titled “Recording-time errors”The builder callback runs exactly once, and evs validates everything it can the moment a
builder method is called. The error is thrown synchronously from inside your evscript
callback, so the ordinary JavaScript stack trace points at your line:
import { evscript, EvsTypeError, t } from '@maxencerb/evs';
try { evscript({ name: 'overflowing', args: [t.uint8] }, (s, x) => { const y = x.add(300n); // 300 does not fit uint8 — throws here, at recording return s.return({ y }); });} catch (e) { if (e instanceof EvsTypeError) { const code = e.code; // 'LITERAL_RANGE' const where = e.stack; // the stack trace includes the line of the offending .add(300n) }}Three classes can surface at recording:
| Class | Thrown for |
|---|---|
EvsTypeError |
type mismatches, literal range violations, ambiguous overloaded calls, unsupported types, folds that would certainly panic |
EvsStagingError |
using a handle as a plain JS value — valueOf, toString, template strings, JSON.stringify |
EvsScopeError |
foreign handles, captures inside s.fn, LoopCtl outside its loop, builder calls after s.return |
Staging misuse deserves a callout because TypeScript catches most of it too. This does not
compile, and each line would throw EvsStagingError at recording if it did:
const sum = x + 1n; // Expr<'uint256'> is not a bigint — use x.add(1n)const label = `balance: ${bal}`; // template-string coercion on a handle throwsSee values and types for the full staging model.
Compile-time errors and diagnostics
Section titled “Compile-time errors and diagnostics”compile() is reserved for whole-program facts. It throws EvsCompileError for hard failures —
COMPILE_LIMIT when the runtime exceeds the EIP-170 cap (with a per-region size breakdown; see
EVM targets) and EVM_VERSION for an unknown target. Internal verifier
failures throw EvsInternalError, whose message always contains “bug in evs, please report”.
Non-fatal findings are warnings, delivered only through the onDiagnostic callback —
compile() never logs:
import { compile, evscript, EvsCompileError, t, type EvsDiagnostic } from '@maxencerb/evs';
const echo = evscript({ name: 'echo', args: [t.uint256] }, (s, x) => s.return({ x }),);
const warnings: EvsDiagnostic[] = [];try { compile(echo, { onDiagnostic: (d) => warnings.push(d) });} catch (e) { if (e instanceof EvsCompileError) { const code = e.code; // 'COMPILE_LIMIT' (EIP-170) or 'EVM_VERSION' (unknown target) }}Each EvsDiagnostic is { severity: 'warning', code, message, site? } with five possible
codes: LOOP_ALLOCATION (memory allocated on every loop iteration — evs never resets the free
pointer), LARGE_FRAME, ENV_FRAME_DEPENDENT (s.env('caller')/s.env('address') and the
script’s own balance, s.balance(s.env('address')) — covered in
executing scripts), and DEPLOYLESS_RESULT_PREFIX /
DEPLOYLESS_RESULT_SIZE (a result shape that can break deployless mode — see
deployless limits). The echo script above raises
DEPLOYLESS_RESULT_PREFIX: its result starts with a uint256 argument, which evs cannot bound
below 0xEF·2^248, so it may begin with 0xEF. site identifies the statement that raised a
per-statement warning, or for DEPLOYLESS_RESULT_PREFIX the one that computes the first
returned value (see diagnostics).
Runtime reverts
Section titled “Runtime reverts”At run time a script reverts with one of four payload families.
Declared custom errors — t.error and s.throw
Section titled “Declared custom errors — t.error and s.throw”Scripts can declare and throw Solidity-style custom errors — a 4-byte selector plus
ABI-encoded parameters. Errors are module-level values created with t.error, declared on
the script def, and thrown with s.throw; throwing an error that is not in the def’s
errors list is a type error in your editor (and a record-time error for untyped callers):
import { evscript, namedArg, t } from '@maxencerb/evs';
// params take the same shorthand as script args: bare types, namedArg, or a mixed listconst NoBalance = t.error('NoBalance', [ namedArg('balance', t.uint256), namedArg('who', t.address),]);const NotOwner = t.error('NotOwner');
const guard = evscript( { name: 'guard', args: [t.uint256, t.address], errors: [NoBalance, NotOwner] }, (s, x, who) => { s.if(x.lt(10n), () => { s.throw(NoBalance, { balance: x, who }); // named record — every param is required }); s.if(x.eq(999n), () => { s.throw(NotOwner); // zero-param — no args }); return s.return({ doubled: x.mul(2n) }); },);The revert data is exactly Solidity’s: selector ‖ abi.encode(params), with the selector
derived from the canonical signature (NoBalance(uint256,address)). Fully-named params are
thrown with a record; if any param is declared bare (t.error('BadPair', [t.address, t.address])), the throw takes a positional tuple instead. Struct and array params work like
everywhere else in evs.
Declared errors are appended to the compiled script’s abi, so viem decodes them natively
— no wrappers, no extra plumbing (see decoding script errors
below). Errors thrown inside an s.fn body are checked against the same
declared set — one error namespace per script.
Panics — Panic(uint256)
Section titled “Panics — Panic(uint256)”evs uses Solidity 0.8’s Panic(uint256) convention. Your script’s own code emits exactly four
codes; everything else can still reach you because callee reverts bubble verbatim:
| Code | Meaning | Emitted by your script? |
|---|---|---|
0x00 |
generic compiler panic | bubbled from a callee only |
0x01 |
assertion failure (assert) |
bubbled from a callee only |
0x11 |
arithmetic overflow or underflow | yes — checked add/sub/mul/pow, signed div (intN min ÷ −1), mulDiv/mulDivRoundingUp quotient past uint256, narrowing conversions |
0x12 |
division or modulo by zero | yes — div/mod/addmod/mulmod/mulDiv/mulDivRoundingUp |
0x21 |
invalid enum conversion | bubbled from a callee only |
0x22 |
corrupted storage byte array | bubbled from a callee only |
0x31 |
pop on an empty array | bubbled from a callee only |
0x32 |
array index out of bounds | yes — .at(), MutArray get/set, .byteAt(), .slice() |
0x41 |
allocation too large (out of memory) | yes — s.newArray with a runtime length ≥ 2^32 |
0x51 |
call to a zero-initialized internal function | bubbled from a callee only |
See arithmetic for exactly which operations check what.
The evs error ABI
Section titled “The evs error ABI”Two custom errors are evs’s own. They are exported as EVS_ERROR_ABI and — more usefully —
already included in every compiled script’s abi, so viem names them when decoding a revert:
| Error | Reverted when |
|---|---|
EvsInvalidCalldata() |
the calldata does not match the script function: wrong selector, truncated calldata, malformed dynamic arguments |
EvsDecodeError(uint256 site) |
a strict s.read received structurally malformed returndata; site indexes the artifact’s source map |
Bubbled callee reverts
Section titled “Bubbled callee reverts”When a contract called via s.read reverts, the script reverts with the callee’s payload
byte-for-byte — Error(string), Panic(uint256), and custom errors alike. Nothing is
wrapped or rewritten, so you can decode the payload against the callee’s ABI as if you had
called it directly. If you want a failed sub-call to produce zeros instead of reverting, use
s.tryRead — see calling contracts.
Failures without a script payload
Section titled “Failures without a script payload”Some failures never reach the script’s revert families, and their payload says nothing about the cause:
- Deployless creation limits. The default
toViem()runs as a contract creation, so the node rejects a result over 24,576 bytes, a result starting with byte0xEF, or a payload over 49,152 bytes. viem throws with the node’s reason indetails(max code size exceeded,CreateContractStartingWithEF,max initcode size exceeded, …).explainDeploylessError(error)turns it into a typed explanation naming the rule. See deployless limits. - Empty reverts in deployless mode. When the script itself dies without revert data — it
runs out of gas, or the pinned block lacks an opcode the artifact uses (
PUSH0,MCOPY) — viem’s wrapper reverts with empty data, andexplainRevertreports kind'empty'. Re-run the call withtoViem({ mode: 'stateOverride' })to see the node’s own error, and check the artifact’sevmVersionagainst the pinned block. - An unfunded
value. Avalueons.call/s.simulateis paid from the script’s own balance, and when that balance falls short theCALLfails before the target runs:s.callreverts with empty data, whiles.simulateand ans.callwithrevertReturnsrevert withEvsDecodeError(site).explainRevertnames the value-sending sites (sourceMap.sitesmarks themsendsValue) as a candidate cause in each case. Deployless mode cannot fund the script, socompilewarnsENV_FRAME_DEPENDENTfor every such site; fund it with abalancein the state-override entry.
Decoding script errors on the client
Section titled “Decoding script errors on the client”Because the declared errors ride in the artifact ABI, a plain readContract already throws a
decoded ContractFunctionRevertedError (errorName + args). For an exhaustive, typed
switch over your error set, pass the caught error itself (or raw revert bytes) to
matchScriptError — it requires one handler per declared error plus a _ default arm for
everything else (panics, bubbled callee errors, unknown selectors):
import { compile, evscript, matchScriptError, namedArg, t } from '@maxencerb/evs';import { createPublicClient, http } from 'viem';
const NoBalance = t.error('NoBalance', [namedArg('balance', t.uint256)]);const NotOwner = t.error('NotOwner');
const guard = evscript( { name: 'guard', args: [t.uint256], errors: [NoBalance, NotOwner] }, (s, x) => { s.if(x.lt(10n), () => { s.throw(NoBalance, { balance: x }); }); return s.return({ doubled: x.mul(2n) }); },);
const compiled = compile(guard);const client = createPublicClient({ transport: http('https://eth.merkle.io') });
try { await client.readContract({ ...compiled.toViem(), functionName: 'guard', args: [5n] });} catch (e) { const message = matchScriptError(compiled, e, { NoBalance: ({ balance }) => `insufficient balance: ${balance}`, // args typed from the declaration NotOwner: () => 'caller is not the owner', _: (other) => `unexpected revert: ${other.name}`, // Panic | Error | Evs* | unknown | empty });}Adding an error to the script without updating the switch is a type error — the handler
record is exhaustive over the declared set. If the caught value carries no revert data at all
(a network error, a timeout), matchScriptError rethrows it instead of routing it to _
— transport failures never masquerade as script errors. The error tree is inspected by shape,
not by class, so a revert thrown by a different copy of viem than the one evs resolved (a
second bundle, a pnpm-duplicated version) still decodes.
Under the sugar sits decodeScriptError(script, errorOrHex), which returns a
name-discriminated union you can switch on directly — one arm per ABI error entry
(declared errors with a name-keyed, abitype-typed args record, plus the evs built-ins) and
built-in arms for Panic (code + meaning), Error (reason), unknown (selector + raw — e.g.
a callee error bubbled verbatim), and empty. It returns undefined when the input is not
revert-shaped.
explainRevert — from a viem error to the failing site
Section titled “explainRevert — from a viem error to the failing site”The artifact’s explainRevert(data) classifies any revert payload and, where possible, maps it
back to the site (a numbered read, check, or call in your script) that produced it. The full flow — catch the viem error, extract the raw
payload, explain it:
import { compile, evscript, t } from '@maxencerb/evs';import { BaseError, ContractFunctionRevertedError, createPublicClient, erc20Abi, http,} from 'viem';
const tokenMeta = evscript({ name: 'tokenMeta', args: [t.address] }, (s, token) => { const symbol = s.read({ address: token, abi: erc20Abi, functionName: 'symbol' }); return s.return({ symbol });});
const compiled = compile(tokenMeta);const client = createPublicClient({ transport: http('https://eth.merkle.io') });
try { await client.readContract({ ...compiled.toViem(), functionName: 'tokenMeta', args: ['0x000000000000000000000000000000000000dEaD'], // no symbol() here });} catch (e) { if (e instanceof BaseError) { const reverted = e.walk((err) => err instanceof ContractFunctionRevertedError); if (reverted instanceof ContractFunctionRevertedError && reverted.raw !== undefined) { const explanation = compiled.explainRevert(reverted.raw); // explanation.kind → 'evs-decode' // explanation.message → "decoding symbol() returndata failed (EvsDecodeError site 0) // — note: the script has strict call sites that bubble callee // reverts, and a callee may have reverted with this selector (…)" // explanation.site → { id, detail } for the s.read that failed } }}The result is a RevertExplanation: { kind, message, raw } plus panicCode for panics,
site for decode errors and candidateSites wherever several sites could be the origin. The
kinds:
kind |
Payload | Source attribution |
|---|---|---|
'panic' |
Panic(uint256) |
candidateSites — every site in this script that can raise that panic code |
'evs-decode' |
EvsDecodeError(uint256 site) |
site — the exact s.read whose returndata failed to decode (id + detail); for an s.simulate or revertReturns site that sends a value, also an insufficient script balance for that value |
'evs-invalid-calldata' |
EvsInvalidCalldata() |
the script’s own signature is named in the message |
'script-error' |
a DECLARED custom error | errorName + errorArgs (name-keyed decoded args) — thrown by this script’s s.throw |
'error-string' |
Error(string) |
candidateSites — the strict call sites a callee revert can bubble through |
'custom' |
any other selector | candidateSites as above — decode the payload against the callee’s ABI |
'empty' |
zero-length returndata | candidateSites as above, an insufficient script balance for an s.call value, or the frame failed without reason (see below) |
An empty payload has one more origin: the frame failing without data — out of gas, or an
opcode the node does not support at that block. The default cancun target uses PUSH0 and
MCOPY, which are invalid before Shanghai and Cancun, so a historical read (or a chain that has
not activated those forks) fails this way; the message names the fork-gated opcodes the runtime
uses, and recompiling with an older evmVersion ('paris' runs everywhere) fixes it.
disassemble() — read the bytecode
Section titled “disassemble() — read the bytecode”disassemble() returns the annotated instruction listing of the runtime bytecode; format()
renders it with labels, jump targets, and notes:
import { compile, evscript, lookupPc, t } from '@maxencerb/evs';
const echo = evscript({ name: 'echo', args: [t.uint256] }, (s, x) => s.return({ x }),);const compiled = compile(echo);
const listing = compiled.disassemble().format();// pc, raw bytes, mnemonic, jump targets, notes — see below
const hit = lookupPc(compiled.sourceMap, 0x29);// hit → { note?: string } | undefinedThe first lines of the real listing for this echo(uint256) script (cancun target):
0x0000 60a0 PUSH1 0xa0 ; frameEnd0x0002 6040 PUSH1 0x400x0004 52 MSTORE ; free-ptr init0x0005 6004 PUSH1 0x040x0007 36 CALLDATASIZE0x0008 10 LT0x0009 610048 PUSH2 0x0048 → @badcd0x000c 57 JUMPI0x000d 5f PUSH00x000e 35 CALLDATALOAD0x000f 60e0 PUSH1 0xe00x0011 1c SHR0x0012 636279e43c PUSH4 0x6279e43c ; selector echo(uint256)0x0017 14 EQ0x0018 610020 PUSH2 0x0020 → @maindisassemble also works on foreign bytecode — it is a free export taking raw bytes or hex.
sourceMap and lookupPc
Section titled “sourceMap and lookupPc”The artifact’s sourceMap is the data behind both tools: segments cover every emitted pc range
(with a short note on what the code does; consecutive instructions that share a note, or have none,
form one segment), sites give each panic/decode/call site an id, a kind, a human-readable detail
naming its operands, and — on panic sites — the panicCodes it can raise (this is what
explainRevert resolves EvsDecodeError(site) and panic candidates against), and labels name
the pcs of internal jump targets. lookupPc(map, pc) binary-searches the segments — handy for
decorating an execution trace from a node or a fork tool with what each pc range belongs to.
For debugging script logic without a node at all, run the IR oracle instead — see testing scripts. Full artifact shapes are in the artifact reference.