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.
evmVersion
Section titled “evmVersion”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: 0x61RRRR80600a3d393df3Any other string throws EvsCompileError with code 'EVM_VERSION' before any work happens.
The chosen target is recorded on the artifact under options.evmVersion.
What changes per target
Section titled “What changes per target”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) |
PUSH0is EIP-3855, activated in Shanghai. Onparisevery zero push widens toPUSH1 0x00, and the deployless init wrapper swaps its twoPUSH0bytes forRETURNDATASIZE, which is guaranteed zero at the start of an init frame.MCOPYis EIP-5656, activated in Cancun. On older targets memory copies go through a shared@memcpyword-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.
Picking a target
Section titled “Picking a target”| 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.
The EIP-170 size cap
Section titled “The EIP-170 size cap”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.litstrings, 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.whileemit one body — see control flow. - Repeated logic can be factored into an
s.fnsubroutine, 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.