How it works
compile() turns the statement tree your builder callback recorded into a single-function EVM
contract that runs under eth_call. The pipeline is deliberately short and unclever: one
IR-level pass (dead-code elimination, which only ever removes work nothing observes), an
optimizer that stays off unless you ask for it (optimize: true), and a set of
machine-checked invariants that run on every compile — optimized or not. Each stage
produces an artifact you can inspect.
your callback s.read / a.add(b) / s.if / s.while / s.fn / s.return | recording: TS types + eager runtime validation vScriptIr structured statement tree over a flat value table — plain JSON, frozen | validateIr (always re-checked, even for IR you just recorded) | eliminateDeadCode: drop statements whose results nothing observable reads | (re-validated) vAsmNode[] codegen: frame layout (liveness-packed with optimize: true) -> | dispatcher -> statement templates -> | call/ABI emitters -> shared revert tails -> data segments | optimize: true -> built-in peephole pass (off by default) | peephole hook (default: identity) -> assemble: layout -> EIP-170 size check | -> patch PUSH2 fixups -> verify: JUMPDEST scan + stack-height simulation + shape lints vCompiledEvsScript runtimeBytecode, initBytecode, literal-typed abi, sourceMap, ir, toViem(), disassemble(), explainRevert()import { compile, evscript, t } from '@maxencerb/evs';
const echo = evscript({ name: 'echo', args: [t.uint256] }, (s, x) => s.return({ x }),);
const compiled = compile(echo);
const ir = compiled.ir; // stage 1: the recorded statement tree (also `echo.ir`)const bytecode = compiled.runtimeBytecode; // the verified runtime bytecodeconst listing = compiled.disassemble().format(); // annotated mnemonic listing: labels, jump targets, notesconst map = compiled.sourceMap; // pc -> location segments + revert-site tableStage 1: recording produces ScriptIr
Section titled “Stage 1: recording produces ScriptIr”Your callback runs exactly once, at recording time. Every builder call appends a statement to
the script’s IR: a structured statement tree (if/while nest, everything else is flat) over
a table of typed values. The IR is plain JSON-safe data — words are 0x-hex strings, the format
is versioned (irVersion: 1) — and it is frozen the moment recording ends. It is exposed as
script.ir and again as compiled.ir, which makes it snapshot-testable and attachable to bug
reports. See Writing scripts for the recording model itself.
Every statement carries a site id, which is how explainRevert and the source
map trace a revert or a program counter back to the read, check, or call that produced it.
Stage 2: validateIr
Section titled “Stage 2: validateIr”compile() always re-validates the IR before lowering it — every declared type (no empty
tuple, at most four nested array levels, no static type of 2^32 bytes or more), operand types
per operation, definition-before-use under the scope rules, cell types, call-graph acyclicity,
return names, and in-place writes only into arrays the program allocated itself. The builder
already enforced all of this at recording time but one size: a struct output of a raw ABI whose
members each fit but whose total is 2^32 bytes or more is recorded, and this pass is the first
to reject it (UNSUPPORTED_V0, from compile() and interpret() alike). Validating again
also means IR loaded with deserializeIr (see Testing scripts)
cannot reach code generation in a shape the builder would never record. The one builder rule
the IR layer relaxes is a non-empty return list: an IR program may return nothing (it returns
0x).
Stage 3: dead-code elimination
Section titled “Stage 3: dead-code elimination”The one IR-level pass. compile() runs it on every compile, and it is exported as
eliminateDeadCode (alias dce) so you can apply it to any ScriptIr yourself. It drops
statements whose results nothing observable depends on, and nothing else:
- What is live: every returned value, every
s.throwargument, every sub-call (s.read,s.call,s.simulateand theirtry*forms — with or without a used result), every loop, everys.letcell that is read somewhere, and everys.fncall whose callee is not pure (it makes a sub-call, throws, loops, or writes an array/struct it was passed). Everything a live statement reads is live, recursively; ans.ifis live if anything inside it is. - What is dropped: arithmetic, comparisons, conversions, literals,
array.at(i),array.length(),s.newArray, struct/array construction and member reads,s.encode,s.keccak256,.get()on a cell, a.set()on a cell nothing ever reads, and a call to a pures.fn— whenever the value produced is never used. Writes to an array or struct stay whenever anything that may alias the same memory is live. - Revert guards are not side effects. A checked
addthat overflows, anarray.at(i)out of bounds, a narrowingconvert— these revert, but when the value they guard is never read the guard is dead work too, and the pass removes it: a script that overflows while computing an unused sum returns instead of panicking. This is an evs choice, not Solidity’s: solc keeps the Panic of an unuseda - b;, with or without its optimizer. Anything that must fail has to feed a return, as.throw, a sub-call, or a cell that is read.interpret()runs the same pass by default, so it agrees with the bytecode here;{ dce: false }runs the recorded IR (see Testing scripts).
The pass keeps the value table and every surviving statement — its site id, its source
location — untouched, so the source map and explainRevert still resolve, and compiled.ir
stays the recorded IR (script.ir). It is idempotent, its output is re-validated before
lowering, and the differential suite checks the recorded IR, dce(ir) and the compiled
bytecode agree on the whole corpus, except where a dropped guard is the statement that would
revert (see Why trust the bytecode?). One practical consequence:
s.forEach(xs, () => { ... }) with an unused element records the element load like any other
iteration, and the pass removes it — no builder special case.
Stage 4: codegen lowers IR to an assembly stream
Section titled “Stage 4: codegen lowers IR to an assembly stream”Lowering assigns the frame layout (one 32-byte memory slot per argument, cell, value, and function parameter/result — see the memory model below), then emits, as one node stream:
- the dispatcher: a receive check (empty calldata stops successfully, so a target can pay ETH back to the script), the free-pointer prologue, a single 4-byte selector compare (a script is a one-function contract), and the calldata-size guard;
- the argument decoder: each script arg is read from calldata, bounds-checked (dynamic args get full offset/length validation), normalized to its canonical word form, and stored to its frame slot;
- one statement template per IR statement — load operands from slots, compute, store the result back;
- the call emitters for every sub-call verb (
s.read,s.call,s.simulateand theirtry*forms), the ABI encoders (s.encode,s.encodePacked) and the return-tuple encoder; - the shared codec subroutines (
@enc_<k>/@dec_<k>): when the same struct or composite array is encoded or decoded at several sites (or the sames.simulateoutputs are decoded twice), its encoder or decoder is emitted once and each site calls it — only when that makes the program smaller, and never inside a loop where the call would cost gas; - the shared revert tails: one
Panic(uint256)tail (entered via per-code stubs), theEvsDecodeError(uint256 site)tail, and theEvsInvalidCalldata()fallback — each emitted only when some code jumps to it; - data segments for large constants, referenced only by
CODECOPY.
Compile-time diagnostics (LOOP_ALLOCATION, LARGE_FRAME, ENV_FRAME_DEPENDENT,
DEPLOYLESS_RESULT_PREFIX, DEPLOYLESS_RESULT_SIZE) are emitted
during this stage through the onDiagnostic callback — compile() never logs. See
Errors & diagnostics.
Stage 5: assembly
Section titled “Stage 5: assembly”The assembler turns the node stream into bytes: opcodes, minimal-width PUSH immediates, and
label pushes. Label pushes are always emitted as PUSH2 plus a fixup that is patched in after
layout — the EIP-170 size cap, checked as soon as layout fixes the final length and before any
fixup is patched, keeps every offset within 16 bits, so PUSH2 always suffices and no
narrowing pass exists to get wrong. Data segments are placed after the last code byte behind
a single INVALID (0xFE) guard byte, so execution can never fall through into data.
Between codegen and assembly sits the optimizer seam. By default nothing runs there and the
bytes are the plain lowering. compile(script, { optimize: true }) enables the built-in
peephole pass (#39) — a conservative, purely local
rewrite over the node stream: a store immediately reloaded from the same slot becomes
DUP1 + store, arithmetic over two immediates is folded (256-bit wraparound, EVM division-by-zero
semantics, never widening the immediate), and stack identities such as SWAP1 SWAP1, DUP1 POP
or PUSH 0 ADD are dropped. It never rewrites across a JUMPDEST, never touches a label or a
jump target, keeps every source-map site, and runs before the verifiers below, so optimized
output is checked exactly like unoptimized output. A user peephole hook in CompileOptions
runs at the same position, after the built-in pass when both are on; its output is held to the
same node contract (a bare PUSH1–PUSH32 op node, which would leave its immediate to the next
bytes, and a push node whose value is not a bigint are rejected at layout). The pass is exported as
evsPeephole (peephole: evsPeephole runs only this pass; optimize: true also switches
codegen to the liveness-based frame allocator described in the memory model).
Stage 6: mandatory verifiers
Section titled “Stage 6: mandatory verifiers”After assembly, three verification passes run on the final bytecode. They are always on — not
a debug mode — and a failure throws EvsInternalError (“this is a bug in evs, please
report”), never silently ships bad bytecode.
- JUMPDEST scan. The bytecode is scanned exactly the way consensus clients do (skipping
PUSHimmediates), and the verifier asserts that every patched jump target lands on a realJUMPDEST, every label was defined, no fixup is left unpatched, and no jump targets the data segment. - Stack-height simulation. The verifier walks every path and checks the heights the
codegen contract promises: the operand stack is empty at every statement boundary, every
statement template is net-zero on the stack, simulated depth never exceeds 16 inside a
template (the
DUP/SWAPreach), and every label join sees one consistent height. Revert tails are tracked as terminating regions that must end inREVERT,INVALID, orRETURN. - Shape lints. Every
RETURNDATACOPYmust be one of the two intrinsically safe shapes (offset 0,size RETURNDATASIZE— which can never read out of bounds, so the all-gas-consuming halt is unreachable by construction); no opcode newer than the selectedevmVersionmay appear (catches a strayMCOPYin aparisbuild — see EVM targets); andSLOAD,SSTORE,TLOAD,TSTORE,LOG0–LOG4,CREATE,CREATE2,CALLCODE,DELEGATECALLandSELFDESTRUCTmust never appear — a script never touches its own storage, emits logs or deploys contracts.CALLis allowed, and emitted only by the mutable-call verbss.call/s.simulate(and theirtry*forms): its effects stay inside theeth_calland are discarded when it ends (s.simulateadditionally reverts its own frame).
Stage 7: the EIP-170 size check
Section titled “Stage 7: the EIP-170 size check”Runtime bytecode is capped at 24,576 bytes (EIP-170). Exceeding it throws EvsCompileError
with a per-region size breakdown (dispatcher, body, functions, shared tails, data segments, plus
the simulate trampoline and the shared codec subroutines when present) so you can see
what to trim. The check runs from the assembler’s layout pass, before label fixups are patched
and before the verifiers, so every oversized script gets this error however large it is — even
one that host-side loops unrolled past 64 KiB. The artifact that comes out the other side — runtimeBytecode, the deployless
initBytecode wrapper, the literal-typed ABI, the source map, and the debugging methods — is
documented in The compiled artifact.
The memory model
Section titled “The memory model”Scripts use Solidity’s memory layout verbatim:
| Range | Use |
|---|---|
0x00–0x3f |
scratch — revert-payload assembly and intra-template temporaries only |
0x40–0x5f |
free-memory pointer |
0x60–0x7f |
zero slot — never written; the canonical empty memref (try* failure values point here) |
0x80…frameEnd |
static frame: one 32-byte slot per arg, cell, value, and fn param/result (packed by liveness with optimize: true), then, only when a codec subroutine is shared, up to three codec register words (its return address, base and source pointer), so frameEnd and the free pointer start after them |
frameEnd… |
bump allocations: returndata snapshots of memref outputs, dynamic values, mutable arrays, the return tuple |
The very first instructions of every script store frameEnd into the free pointer at 0x40.
From there:
- Every value has a fixed slot. A statement template loads its operands from their slots
(or pushes a folded constant), computes, and stores the result to the output’s slot. There
is no stack scheduling, and by default no slot reuse either — which is exactly what makes
the empty-stack invariant checkable and the disassembly legible. With
optimize: truethe liveness-based allocator (#41) keeps the templates and gives a value the slot of one that is dead: a value is live from its defining statement to its last read, args, cells and fn params keep dedicated slots, fn frames stay separate, a value crossing a loop boundary stays live for the whole loop, and a value read in oneifbranch stays live through the wholeif. - Every slot holds a canonical word:
uintNzero-extended,intNsign-extended,boolis 0 or 1,bytesNleft-aligned,address160-bit zero-extended. The invariant is established at the three trust boundaries (literal coercion at recording, calldata decode, returndata decode) and preserved by every operation. - Dynamic values are memrefs. A slot for a
string,bytes, or array value holds a pointer to a[length][payload]buffer — strings and bytes as raw zero-padded bytes, arrays as one canonical word per element. - Allocation only bumps. New buffers advance the free pointer and are never freed, so
allocations inside a loop grow memory monotonically —
compile()warns with aLOOP_ALLOCATIONdiagnostic when an allocating statement sits inside a loop: a sub-call with astring/bytes/array/tuple output (or anys.simulate),s.newArray,s.tuple(…)or a struct literal,s.encode/s.encodePacked(ands.keccak256over values that need encoding), a dynamic literal, or a call to ans.fnwhose body allocates (seeLOOP_ALLOCATION). A read whose outputs are all words copies them into their slots straight from a transient returndata snapshot, so it allocates nothing (unless its arguments need encoder state, below). - Memory above the free pointer is not guaranteed zero. Sub-call calldata is built there
transiently without bumping, and a failed
try*decode rolls the free pointer back over its returndata copy. So the return encoder zero-pads dynamic tails explicitly, and a fresh allocation is zero-filled whenever one of its words would otherwise be read unwritten — for example a word-elements.newArray, ans.tuplewith an omitted word member, or the zero value of a struct with a word member or of a fixed-size word array. Blocks whose every word is written right away (astring[]’s element pointers, a struct of only strings and arrays, a literal) skip the fill. (Arguments holding fixed-size arrays, arrays of structs, strings, bytes or arrays, or structs nested three or more dynamic levels deep need a few words of encoder state, reserved just below the calldata: such a call advances the free pointer by those words, and warns withLOOP_ALLOCATIONinside a loop. So does a call with a struct argument, or a fixed-size array or an array of structs, strings, bytes or arrays, that also takes a literalstringorbytesor an all-literal word array: its arguments are encoded without folding literals into the calldata, so each such literal is first copied into a block allocated at the free pointer.)
What evs deliberately does not do (yet)
Section titled “What evs deliberately does not do (yet)”evs optimizes for auditable correctness over gas:
- No optimization by default. Dead-code elimination (Stage 3) always runs, but it only ever
removes work nothing observes. The built-in optimizer is opt-in (
optimize: true) and deliberately small: the peephole pass (#39) folds the back-to-backMSTORE/MLOADpairs the uniform slot lowering produces, constants, and stack identities, and the liveness-based frame allocator (#41) reuses the slots of dead values — nothing more. There is no stack scheduling: every value is still stored to and loaded from memory. The remaining overhead is a cost in gas, never in correctness, andeth_callgas budgets are enormous (a sum loop costs roughly 2M gas for 10,000 iterations — about 200 gas per iteration — far inside any node’s cap). - No call deduplication. The first
STATICCALLto an address costs 2600 gas, repeats cost 100; evs does not dedupe repeated calls. - The ABI type vocabulary is supported: structs and tuples (including nested structs),
arrays of structs (
tuple[],tuple[][]), nested arrays (uint256[][][],string[][],bytes[][]), and fixed-size arrays (uint256[3],Position[2], mixed with dynamic suffixes), up to four array levels — across script args, returns, and call ABIs, byte-exact vs viem and real solc. Deeper arrays are rejected with a recording-time error (#4). The decoder keeps a fast stack-based loop for the one- and two-level shapes (T[],T[][],tuple[],string[]) and switches to heap-allocated loop frames for the deeper ones, so the common shapes pay no gas for the others (#52). A fixed-size word array is copied in bulk rather than decoded element by element: oneMCOPYoncancunfor full-word elements (uint256[3],bytes32[2]), a short copy-and-clean loop for narrower ones (address[2],uint8[4]) and beforecancun. A chain of nested structs costs the same code per level however deep it goes. - Read-only by construction. The script’s own code never writes storage, emits logs or
deploys contracts (enforced by the shape lints above), and the effects of
s.call/s.simulateCALLframes are never committed, because the whole execution is aneth_call.