Skip to content

EVM targets

Different chains run different EVM forks. A script compiled with opcodes the target chain has not activated fails at run time — an unsupported opcode aborts the whole eth_call — so compile() takes an evmVersion and selects instructions accordingly.

CompileOptions.evmVersion accepts 'paris', 'shanghai', or 'cancun'. The default is 'cancun'. The union is exported as the type EvmVersion:

import { compile, evscript, t, type EvmVersion } from '@maxencerb/evs';
const echo = evscript({ name: 'echo', args: [t.uint256] }, (s, x) =>
s.return({ x }),
);
const target: EvmVersion = 'paris';
const compiled = compile(echo, { evmVersion: target });
// compiled.options.evmVersion → 'paris'
// compiled.initBytecode begins with the paris wrapper: 0x61RRRR80600a3d393df3

Any other string throws EvsCompileError with code 'EVM_VERSION' before any work happens. The chosen target is recorded on the artifact under options.evmVersion.

The same script compiles to behaviorally identical bytecode on all three targets; only the instruction selection differs:

Construct cancun (default) shanghai paris
zero constants PUSH0 PUSH0 PUSH1 0x00
memory copies MCOPY @memcpy word-loop subroutine @memcpy word-loop subroutine
init wrapper 61RRRR80600A5F395FF3 61RRRR80600A5F395FF3 61RRRR80600A3D393DF3 (3D = RETURNDATASIZE-as-zero)
  • PUSH0 is EIP-3855, activated in Shanghai. On paris every zero push widens to PUSH1 0x00, and the deployless init wrapper swaps its two PUSH0 bytes for RETURNDATASIZE, which is guaranteed zero at the start of an init frame.
  • MCOPY is EIP-5656, activated in Cancun. On older targets memory copies go through a shared @memcpy word-loop subroutine (about 35 bytes, 67 gas per copied word), emitted once per pre-Cancun build, and only when the script copies memory. The one exception is the decode of a fixed-size word array (uint256[3], …), which happens mid-template: before Cancun it copies with a short inline word loop instead.

The assembler’s verifier enforces the gate as a backstop: no opcode newer than the chosen target can survive into the bytecode (a stray MCOPY in a paris build is an internal error, not a silently broken artifact).

Because instruction selection differs, bytecode size differs slightly per target — compile one artifact per chain family rather than reusing bytes across targets.

Target Use for
cancun Ethereum mainnet and L2s/sidechains on current forks — the default, smallest output
shanghai chains that have PUSH0 (EIP-3855) but not MCOPY (EIP-5656)
paris chains that have not activated Shanghai — no PUSH0; this is the compiler’s oldest supported fork

When in doubt about an older chain or L2, step down: everything paris emits has been available since the Merge, so it is the safe floor. If a script that works on mainnet fails on another chain with an invalid-opcode-style error, suspect PUSH0/MCOPY and recompile with an older target.

Historical reads follow the same rule: the target must match the fork active at the pinned block, not the chain’s current one. On Ethereum mainnet the default cancun build runs from block 19,426,587, shanghai from 17,034,870, and paris covers the blocks before that back to Constantinople (block 7,280,000; Istanbul, block 9,069,000, for a script that reads s.env('chainid')) — older blocks lack the SHL/SHR every build uses. In the default deployless mode a mismatch surfaces as a bare “execution reverted” with no revert data — see block pinning.

Runtime bytecode is capped at 24,576 bytes (EIP-170, the limit on deployed code). The protocol applies it in deployless mode, where viem’s wrapper deploys the script inside the call; state-override mode installs the code without a deployment. compile() enforces the cap for every artifact, so one artifact runs in either mode: exceeding the cap throws EvsCompileError with code 'COMPILE_LIMIT', and the message breaks the size down by region — dispatcher, body, fns, tails, data segments, plus a trampoline bucket when the script has an s.simulate site and a codecs bucket when it shares codec subroutines (a struct or array encoded or decoded at several sites) — ending with the advice to “split the script or move large literals off-chain”.

Practical levers when you hit it:

  • Data segments hold your dynamic literals (s.lit strings, bytes, literal arrays; an all-zero literal is zero-filled at run time instead and takes none, and a sparse one is zero-filled plus a store per nonzero word when that is smaller) — large ones are often better passed as script args at call time, which costs calldata instead of bytecode. In deployless mode that calldata still counts toward the creation payload limit below, so args beyond roughly 48 KB need state-override mode.
  • Body size scales with recorded statements; remember that JS loops unroll at recording time, while s.for/s.while emit one body — see control flow.
  • Repeated logic can be factored into an s.fn subroutine, which is emitted once regardless of call count — see functions.

In deployless mode the runtime cap is not the only budget. The node runs the call as a contract creation, so EIP-3860 caps the whole creation payload — viem’s 398-byte wrapper, the initBytecode (the 10-byte init wrapper plus the runtime) and the encoded call, args included, plus 128 bytes of ABI framing and the 32-byte padding of both blobs — at 49,152 bytes, and the script’s result is deployed as the wrapper’s code, so EIP-170 caps it at 24,576 bytes too. None of these apply in state-override mode; the details and workarounds are in deployless limits. How the regions fit together is covered in how it works.