Skip to content

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
v
ScriptIr 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)
v
AsmNode[] 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
v
CompiledEvsScript 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 bytecode
const listing = compiled.disassemble().format(); // annotated mnemonic listing: labels, jump targets, notes
const map = compiled.sourceMap; // pc -> location segments + revert-site table

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.

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).

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.throw argument, every sub-call (s.read, s.call, s.simulate and their try* forms — with or without a used result), every loop, every s.let cell that is read somewhere, and every s.fn call 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; an s.if is 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 pure s.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 add that overflows, an array.at(i) out of bounds, a narrowing convert — 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 unused a - b;, with or without its optimizer. Anything that must fail has to feed a return, a s.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.simulate and their try* 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 same s.simulate outputs 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), the EvsDecodeError(uint256 site) tail, and the EvsInvalidCalldata() 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.

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).

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.

  1. JUMPDEST scan. The bytecode is scanned exactly the way consensus clients do (skipping PUSH immediates), and the verifier asserts that every patched jump target lands on a real JUMPDEST, every label was defined, no fixup is left unpatched, and no jump targets the data segment.
  2. 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/SWAP reach), and every label join sees one consistent height. Revert tails are tracked as terminating regions that must end in REVERT, INVALID, or RETURN.
  3. Shape lints. Every RETURNDATACOPY must 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 selected evmVersion may appear (catches a stray MCOPY in a paris build — see EVM targets); and SLOAD, SSTORE, TLOAD, TSTORE, LOG0–LOG4, CREATE, CREATE2, CALLCODE, DELEGATECALL and SELFDESTRUCT must never appear — a script never touches its own storage, emits logs or deploys contracts. CALL is allowed, and emitted only by the mutable-call verbs s.call/s.simulate (and their try* forms): its effects stay inside the eth_call and are discarded when it ends (s.simulate additionally reverts its own frame).

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.

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: true the 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 one if branch stays live through the whole if.
  • Every slot holds a canonical word: uintN zero-extended, intN sign-extended, bool is 0 or 1, bytesN left-aligned, address 160-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 a LOOP_ALLOCATION diagnostic when an allocating statement sits inside a loop: a sub-call with a string/bytes/array/tuple output (or any s.simulate), s.newArray, s.tuple(…) or a struct literal, s.encode/s.encodePacked (and s.keccak256 over values that need encoding), a dynamic literal, or a call to an s.fn whose body allocates (see LOOP_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-element s.newArray, an s.tuple with 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 (a string[]’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 with LOOP_ALLOCATION inside 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 literal string or bytes or 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.)

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-back MSTORE/MLOAD pairs 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, and eth_call gas 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 STATICCALL to 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: one MCOPY on cancun for full-word elements (uint256[3], bytes32[2]), a short copy-and-clean loop for narrower ones (address[2], uint8[4]) and before cancun. 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.simulate CALL frames are never committed, because the whole execution is an eth_call.