Skip to content

Why trust the bytecode?

evs is a compiler. A compiler bug does not throw — it silently returns wrong data from a call that looks successful. So evs is built and tested with compiler discipline: machine-checked invariants on every compile, and a differential test suite that never trusts the codegen to grade its own homework. Three independent reference implementations — an IR interpreter, viem’s ABI codecs, and real solc — must agree with the compiled bytecode, byte for byte.

Axis 1: an independent interpreter as the oracle

Section titled “Axis 1: an independent interpreter as the oracle”

The package exports interpret(), a reference interpreter that executes a script’s IR directly in TypeScript — over bigints and byte arrays, with no assembler and no EVM involved. It implements the same normative specification the codegen implements (the checked-arithmetic tables, the call decode bounds, the ABI shapes), so a codegen bug and an interpreter bug would have to make the same mistake to slip through.

The differential suite runs a corpus of scripts covering every operation family, control flow, calls against mocks, the try* verbs, and dynamic returns through both sides: interpret() on one, and the compiled runtimeBytecode executed on an in-process EVM (@ethereumjs/evm) on the other. Both sides see identical callee behavior — the mock chain and the EVM fixtures are generated from the same table — and must agree byte for byte on:

  • returndata, and
  • revert payloads: Panic(code) bytes, EvsDecodeError(site) ids, callee reverts bubbled verbatim, and try* zeroing.

The same corpus gates the dead-code elimination pass compile() runs (How it works). interpret() runs that pass too, so the oracle executes the program that ships; the suite additionally runs the recorded IR (interpret(script.ir, args, chain, { dce: false })) and requires it to agree, byte for byte, for every script whose dead code cannot revert. The pass output must re-validate, and applying the pass twice must be a no-op.

The one place where the recorded IR and the bytecode legitimately differ is a revert guard whose result nothing reads, such as an unused checked sub that would underflow: the pass drops it, so the bytecode and interpret() return where the recorded IR (and Solidity) would revert. A dedicated slice of the corpus pins that case for each kind of guard the pass drops: checked arithmetic, checked conversions, array bounds and length checks, and calls to pure s.fns (see Unused results do not revert). For the arg sets that do not trip the guard, the recorded IR must still agree byte for byte.

Every script goes through the EVM side twice: the default output and its optimize: true twin, so the opt-in optimizer (the liveness-based frame allocator and the peephole pass) is held to the same oracle — and must additionally never be larger, never use a larger frame, and never cost more gas.

A second variant repeats this in the deployless frame: the initBytecode wrapper is actually CREATEd and called from a wrapper address, and interpret() with matching environment overrides must agree byte-exactly — so the frame-dependence of s.env('caller')/s.env('address') (see Executing scripts) is inside the oracle’s coverage, not a blind spot. Any divergence on any axis is a release blocker; the reference pages’ normative tables adjudicate which side is wrong.

Everything evs encodes or decodes is differentially checked against viem’s codecs:

  • Return encoding: for a fuzzed matrix of return shapes (every word-width class, string/bytes, arrays, mixed orders), the script’s RETURN bytes must equal encodeAbiParameters over the result tuple.
  • Argument decoding: scripts that echo their arguments back must round-trip encodeFunctionData exactly, including dynamic arguments.
  • Sub-call calldata: a mock callee returns its calldata verbatim, and the bytes the script built on-chain are compared against encodeFunctionData for literal, runtime, and mixed arguments.

Solidity 0.8 checked arithmetic has genuinely sharp edges — sub-word multiplication can wrap past 2^256 back into range, and int256(-2**255) / -1 silently wraps at the EVM level. evs does not test its Panic semantics against its own understanding of solc; it tests against solc itself. EvsReference.sol (compiled with solc 0.8.30 exactly, optimizer off) defines one external pure function per operation and width class:

function mulU192(uint192 a, uint192 b) external pure returns (uint192) {
return a * b;
}

The integration suite drives the deployed reference contract and the equivalent evs script with the same seeded boundary corpus — 0, 1, max-1, max, min, -1, the uint192 wrap-back case 2**191 * (2**65 + 1), int256: -2**255 / -1, intN: minN / -1 — on anvil, and asserts identical success values and byte-identical Panic(code) revert payloads. Two more reference contracts cover the rest of the arithmetic surface the same way: EvsMathReference.sol for pow, addmod / mulmod and the signed shifts, and EvsFullMathReference.sol for the wrapping methods (solc unchecked blocks) and mulDiv / mulDivRoundingUp (the FullMath algorithm). The reference contracts also carry their own forge test suite, which runs in CI.

Independently of the test suite, three mandatory verifier passes run inside every compile() call: a consensus-identical JUMPDEST scan, a full stack-height simulation (empty stack at every statement boundary, depth at most 16 inside templates), and shape lints that make the dangerous constructs unrepresentable — only the two intrinsically safe RETURNDATACOPY shapes, no opcode newer than the target evmVersion, and no storage, log or contract-creation opcode in the script’s own code. The only CALL frames are the ones s.call/s.simulate emit, and their effects are never committed because the whole execution is an eth_call. A verifier failure throws EvsInternalError instead of shipping the bytecode.

Decode soundness is tested against attacker-shaped callees, not just well-behaved ones: hand-assembled fixtures (and a Malformed.sol mirror on anvil) return huge head offsets, huge lengths, off-by-one truncations, and empty returndata. The suite asserts the script reverts with EvsDecodeError(site) — attributable to the exact s.read call by its site id — and never hits an exceptional halt that would consume all gas (gas usage is sanity-checked). Dirty high bits in word-typed outputs are the deliberate exception: they are normalized (masked, sign-extended or booleanized, as Solidity’s legacy ABI coder v1 does; viem’s decoder masks address and bytesN the same way but returns a dirty uintN/intN word unmasked and throws on a bool word other than 0 or 1), not reverted — see Calls. A real solc Reverter contract covers the bubbling path end to end: Error(string), Panic, custom errors, and empty reverts must all surface through the script unchanged.

Every bytecode listing in this documentation was generated from real compiler output at the time of writing, not written by hand. No documented example shows an optimization the compiler does not perform.

CI (the repo’s ci.yml) is one serial job, run on every push to main and every pull request, after a single cached vp install --frozen-lockfile (viem is exact-pinned — its types move in patches). Its steps, in order:

Step What it proves
forge build && forge test && vp run codegen the solc 0.8.30 reference and mock contracts compile, pass their own tests, and emit codegen artifacts
vp run build the library builds with vp pack (tsdown: unbundled ESM + .d.ts, JS maps with embedded sources)
vp run fmt:check + vp run lint:ci formatting and type-aware lint
vp run typecheck strict typecheck of every workspace, including the docs site (astro check)
vp run test unit tests (differential oracle, codecs, verifiers) + type-level tests
vp run test:integration the anvil suite (foundry pinned for determinism)
publint + @arethetypeswrong/cli --pack the published package shape resolves correctly (ESM-only profile)

The integration tier executes compiled scripts through every supported path on a real node: setCode plus a plain readContract, the state-override mode, and the deployless code path — plus a canary asserting that passing runtime bytecode where init code belongs fails with an empty 0x revert carrying no diagnostic data, which is why toViem() always hands out initBytecode. A manually dispatched (workflow_dispatch) job additionally runs a fork-mode smoke test that reads real mainnet state.

Tier What runs EVM
unit per-module tests + the differential oracle suites @ethereumjs/evm in-process
types type-level assertions (expectTypeOf) on the ABI and builder inference none — tsc semantics
integration end-to-end execution, checked-math-vs-solc, bubbling, flagship scenario anvil (one instance per worker)

The same oracle that gates releases is exported for your own tests — see Testing scripts.