Skip to content

Compiled artifact

compile(script) returns a frozen CompiledEvsScript — everything you need to execute, inspect, and debug a script. This page documents every property and method. For how to run the artifact against a chain, see execution; for the debugging workflow, see errors and debugging.

import { compile, evscript, t } from '@maxencerb/evs';
import { createPublicClient, http } from 'viem';
const erc20 = [
{
type: 'function',
name: 'totalSupply',
stateMutability: 'view',
inputs: [],
outputs: [{ name: '', type: 'uint256' }],
},
] as const;
const script = evscript({ name: 'totalSupplyOf', args: [t.address] }, (s, token) => {
const supply = s.read({ address: token, abi: erc20, functionName: 'totalSupply' });
return s.return({ supply });
});
const compiled = compile(script);
const client = createPublicClient({ transport: http('https://eth.example.com') });
const result = await client.readContract({
...compiled.toViem(),
functionName: 'totalSupplyOf',
args: ['0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'],
});
// result is typed { supply: bigint } — inferred from the literal-typed abi
const supply: bigint = result.supply;
Member Type Description
abi ScriptAbi<name, args, ret, errs> Literal-typed ABI value: [function, EvsInvalidCalldata, EvsDecodeError, ...declared errors]
runtimeBytecode Hex Deployed-form bytecode; at most 24,576 bytes (EIP-170), enforced
initBytecode Hex The 10-byte init wrapper followed by the runtime — creation bytecode
sourceMap SourceMap PC-to-source segments, revert sites, labels
ir ScriptIr The frozen recorded IR (same object as script.ir)
options Readonly<Required<CompileOptions>> The resolved options the artifact was built with
toViem(o?) overloaded Execution shapes to spread into viem’s readContract
disassemble() Disassembly Annotated instruction listing over runtimeBytecode
explainRevert(data) RevertExplanation Decodes a revert payload against this script’s source map

The artifact object is Object.freezed. script.compile(options) is sugar for compile(script, options) — see evscript for compile, CompileOptions, and their defaults.

The ABI is a value with a literal type: one view function named after the script, followed by the two evs error entries, then one error entry per custom error declared in the def’s errors: [...] (in declaration order; none for the script below). The function has exactly one output — a tuple named result whose components are the keys of your s.return({ ... }) record. Because every component is named, viem infers an object, not a positional array.

For the totalSupplyOf script above, compiled.abi is:

const abi = [
{
type: 'function',
name: 'totalSupplyOf',
stateMutability: 'view',
inputs: [{ name: 'arg0', type: 'address' }], // args are auto-named arg{i} — positional labels
outputs: [
{
name: 'result',
type: 'tuple',
components: [{ name: 'supply', type: 'uint256' }],
},
],
},
{ type: 'error', name: 'EvsInvalidCalldata', inputs: [] },
{ type: 'error', name: 'EvsDecodeError', inputs: [{ name: 'site', type: 'uint256' }] },
] as const;

The runtime ABI array is the encode/decode source of truth: input order is the args tuple order, and component order is the insertion order of the s.return record. The same ABI exists pre-compile as script.abi, so a codegen failure can never corrupt the typed surface. The ScriptAbi type is exported; see types.

  • runtimeBytecode is the deployed form — what actually executes. Compilation fails with EvsCompileError (code COMPILE_LIMIT) if it exceeds the EIP-170 limit of 24,576 bytes; the message includes a per-region size breakdown (dispatcher, body, fns, tails, data segments, plus trampoline / codecs when present).
  • initBytecode is wrapper ++ runtimeBytecode, where the wrapper is a locked 10-byte init stub that CODECOPYs everything after itself and returns it as the deployed code:
61 RRRR PUSH2 len ; runtime length, big-endian
80 DUP1
60 0A PUSH1 0x0A ; the wrapper is 10 bytes; runtime starts right after
5F PUSH0 ; paris target: 3D (RETURNDATASIZE — zero in an init frame)
39 CODECOPY ; mem[0..len) = code[10..10+len)
5F PUSH0 ; paris target: 3D
F3 RETURN ; return the runtime as the deployed code

The init form exists because viem’s deployless code call parameter expects creation bytecode — passing runtime bytecode fails with an empty revert that carries no diagnostic data. The artifact deliberately has no field named code or bytecode; the only place a code key appears is in the toViem() results, where it is always the correct form for that mode.

Five overloads, two modes. Every result spreads directly into viem’s readContract:

Call Returns
toViem() { abi, code: initBytecode } — deployless (default)
toViem({ mode: 'deployless' }) same as the no-argument form
toViem({ mode: 'stateOverride', address? }) { abi, address, stateOverride: [{ address, code: runtimeBytecode }] }
toViem({ mode: 'stateOverride', sender, address? }) { abi, address: sender, stateOverride: [{ address: sender, code: runtimeBytecode }], account: sender } — sender mode: sub-call targets see msg.sender = sender; address, if given, must equal sender
toViem({ mode, address?, sender? }) with mode: ToViemMode (optional, or ToViemMode | undefined) the union of the shapes above — for a mode chosen at run time; an unset mode is deployless; address / sender apply only when mode is 'stateOverride'
import { compile, DEFAULT_SCRIPT_ADDRESS, evscript } from '@maxencerb/evs';
const script = evscript({ name: 'blockNow', args: [] }, (s) => {
return s.return({ ts: s.env('timestamp'), height: s.env('blocknumber') });
});
const compiled = compile(script);
// deployless: plain two-parameter eth_call, maximum RPC compatibility
const deployless = compiled.toViem();
const sameInit: boolean = deployless.code === compiled.initBytecode; // true — creation bytecode
// state override: runtime bytecode installed at a deterministic address
const overridden = compiled.toViem({ mode: 'stateOverride' });
const atDefault: boolean = overridden.address === DEFAULT_SCRIPT_ADDRESS; // true
const installsRuntime: boolean = overridden.stateOverride[0].code === compiled.runtimeBytecode; // true

When the mode is only known at run time — a config value, or a provider probe, since not every provider supports state overrides — type it as the exported ToViemMode ('deployless' | 'stateOverride') and pass it straight through. The result is the union of the shapes, which still spreads into readContract with full return inference:

import { compile, evscript, t, type ToViemMode } from '@maxencerb/evs';
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
const double = compile(
evscript({ name: 'double', args: [t.uint256] }, (s, x) => s.return({ y: x.mul(2n) })),
);
const client = createPublicClient({ chain: mainnet, transport: http() });
async function run(mode: ToViemMode): Promise<bigint> {
const { y } = await client.readContract({
...double.toViem({ mode }),
functionName: 'double',
args: [21n],
});
return y;
}

The mode may also be optional or ToViemMode | undefined — a config field left unset to get the default — and an unset mode is deployless, as for toViem(). A literal mode keeps its own overload and precise shape; { mode: 'deployless', address } is still a type error, and so is an object with no mode key at all (call toViem()). Since the catch-all is the last overload, ReturnType<CompiledEvsScript['toViem']> names that union.

State-override mode accepts an optional address (viem’s Address type) to place the script somewhere else; it requires a provider that supports the third eth_call parameter. The two modes run the script in different execution frames, which matters for s.env('caller') and s.env('address') — see execution for the full comparison, block pinning, and the ENV_FRAME_DEPENDENT warning. Deployless mode runs as a contract creation, so its result is capped at 24,576 bytes (and must not start with 0xEF), its payload — initBytecode plus the encoded args — at about 48.6 KB, and it costs extra gas; state-override mode has none of these limits. See deployless limits below for the checks evs provides, and the execution guide for the gas cost and workarounds.

0xcD360FfAC9818c4396Aa6F4807EBfA72C4B3f530

Exported as a constant (type Address). It is the last 20 bytes of keccak256("evs.script") — an address with no code, storage, or balance on any major chain, so overriding it is safe.

A deployless call has no to, so the node runs it as a contract creation: viem’s 398-byte wrapper contract deploys initBytecode, calls it, and returns the script’s result as its own deployed code. Three contract-creation rules therefore apply in deployless mode, and none of them applies in state-override mode:

Limit Rule Fails when
DEPLOYLESS_MAX_RESULT_BYTES = 24,576 EIP-170 (code size) The ABI-encoded result is larger. A uint256[] result takes 96 + 32·n bytes, so 765 elements fit and 766 do not
no leading 0xEF EIP-3541 The result’s first byte is 0xEF. Only a result whose first returned value is a static bytesN, uint256 or int256 (directly or as the first member of a static struct or fixed array) can start with it: a hash fails about 1 time in 256, while a uint256 needs at least 0xEF·2^248 and an int256 must be below -2^252, which amounts and counts never reach
DEPLOYLESS_MAX_DATA_BYTES = 49,152 EIP-3860 (initcode size) viem’s creation data is larger: the wrapper, initBytecode and the ABI-encoded call including its args

compile() reports the result-shape cases it can see through onDiagnostic: DEPLOYLESS_RESULT_PREFIX and DEPLOYLESS_RESULT_SIZE (see diagnostics). The args side depends on each call, so measure it with deploylessDataSize(compiled, calldata), which does the arithmetic without sending anything:

import {
compile,
DEPLOYLESS_MAX_DATA_BYTES,
deploylessDataSize,
evscript,
explainDeploylessError,
t,
} from '@maxencerb/evs';
import { createPublicClient, encodeFunctionData, http, type Address } from 'viem';
const script = evscript({ name: 'count', args: [t.array(t.address)] }, (s, list) => {
return s.return({ n: list.length() });
});
const compiled = compile(script);
const client = createPublicClient({ transport: http('https://eth.example.com') });
declare const tokens: Address[];
const calldata = encodeFunctionData({ abi: compiled.abi, functionName: 'count', args: [tokens] });
const mode =
deploylessDataSize(compiled, calldata) <= DEPLOYLESS_MAX_DATA_BYTES
? compiled.toViem()
: compiled.toViem({ mode: 'stateOverride' });
try {
await client.readContract({ ...mode, functionName: 'count', args: [tokens] });
} catch (error) {
const limit = explainDeploylessError(error);
if (limit === undefined) throw error; // not a deployless creation limit
limit.kind; // 'result-starts-with-ef' | 'result-too-large' | 'data-too-large'
limit.message; // what happened, and that toViem({ mode: 'stateOverride' }) avoids it
limit.nodeMessage; // the node's own text, e.g. 'max code size exceeded: code size 24608 limit 24576'
}

The node rejects these calls with an error viem often shows only as “Missing or invalid parameters” or “Transaction creation failed”. explainDeploylessError searches the caught error’s cause chain for the node’s text — max code size exceeded and invalid code: must not begin with 0xef (geth and the nodes that reuse its wording), max initcode size exceeded (geth and anvil), CreateContractSizeLimit and CreateContractStartingWithEF (revm’s error names, which anvil reports), and CreateInitCodeSizeLimit (revm’s name for the initcode case, matched in case another revm-based node reports it) — and returns undefined for anything else. size holds the rejected size when the node reports it (geth does) and limit the exceeded cap.

The two evs-owned error entries, exported as a standalone as const ABI and appended verbatim to every script’s abi (entries 1 and 2):

import { EVS_ERROR_ABI } from '@maxencerb/evs';
EVS_ERROR_ABI[0]; // { type: 'error', name: 'EvsInvalidCalldata', inputs: [] }
EVS_ERROR_ABI[1]; // { type: 'error', name: 'EvsDecodeError', inputs: [{ name: 'site', type: 'uint256' }] }
Error Selector Raised when
EvsInvalidCalldata() 0xf43fed56 The calldata does not match the script’s function (wrong selector, truncated calldata, malformed dynamic arguments)
EvsDecodeError(uint256 site) 0x20cf27b7 A strict s.read got returndata that fails to decode; site identifies the recorded call site

Because both entries are in every script’s ABI, viem decodes these reverts into named errors out of the box. The same holds for the custom errors a script declares and throws with s.throw: they are part of the script ABI, and explainRevert reports them as kind 'script-error'. All other reverts (callee Error(string), callee custom errors, panics) bubble through the script byte-exactly — decode those against the callee’s ABI; see calls.

import { compile, evscript, t } from '@maxencerb/evs';
const script = evscript({ name: 'one', args: [] }, (s) => {
return s.return({ v: s.lit(t.uint256, 1n) });
});
const dis = compile(script).disassemble();
const listing: string = dis.format(); // listing with labels, jump targets and notes
for (const line of dis.lines) {
line.pc; // byte offset
line.mnemonic; // opcode name
line.raw; // exact bytes of this instruction
}

Disassembly is { lines, format() }. Each element of lines has:

Field Type Meaning
pc number Byte offset of the instruction
raw Hex Exact bytes — concatenating every raw reproduces the input byte-for-byte
mnemonic string Opcode name; non-opcode bytes (data segments) appear as UNKNOWN_0x<byte>
pushValue? Hex Immediate of a PUSH1–PUSH32
targetLabel? string Label whose pc matches a small push immediate
label? string Label defined at this pc
note? string Codegen annotation (for example free-ptr init)

The first lines of the totalSupplyOf artifact from the top of this page (format(), real compiler output):

0x0000 60c0 PUSH1 0xc0 ; frameEnd
0x0002 6040 PUSH1 0x40
0x0004 52 MSTORE ; free-ptr init
0x0005 6004 PUSH1 0x04
0x0007 36 CALLDATASIZE
0x0008 10 LT
0x0009 6100ba PUSH2 0x00ba → @badcd
0x000c 57 JUMPI
0x000d 5f PUSH0
0x000e 35 CALLDATALOAD
0x000f 60e0 PUSH1 0xe0
0x0011 1c SHR
0x0012 63cf9530d0 PUSH4 0xcf9530d0 ; selector totalSupplyOf(address)
0x0017 14 EQ
0x0018 610020 PUSH2 0x0020 → @main
0x001b 57 JUMPI

A push whose value happens to coincide with some label’s pc gets a speculative → @label annotation; only PUSH2 immediates feeding a JUMP/JUMPI are actual jump targets.

import { compile, evscript, lookupPc, t } from '@maxencerb/evs';
const script = evscript({ name: 'one', args: [] }, (s) => {
return s.return({ v: s.lit(t.uint256, 1n) });
});
const compiled = compile(script);
const hit = lookupPc(compiled.sourceMap, 0);
if (hit !== undefined) {
hit.note; // optional codegen annotation
}
for (const site of compiled.sourceMap.sites) {
site.kind; // 'panic' | 'decode' | 'call' | 'stmt'
site.detail; // human-readable, e.g. 'checked add args.x + 1 (uint256) — Panic 0x11'
site.panicCodes; // 'panic' sites only, e.g. [0x11]
site.sendsValue; // true on an s.call / s.simulate site that sends a `value`
}

SourceMap fields:

Field Shape Notes
version 1 Format version
segments readonly { pc, len, note? }[] Sorted by pc, non-overlapping; together they cover every emitted code byte. Each is a maximal run of consecutive instructions sharing one note (or none), so neighbouring segments always differ
sites readonly { id, kind, detail, panicCodes?, sendsValue? }[] Revert-attribution sites; kind is 'panic' | 'decode' | 'call' | 'stmt'; id is a number; panicCodes (panic sites only) lists the Panic(uint256) codes the site can raise; sendsValue is true on an s.call / s.simulate site (or its try* form) whose value is not a literal 0 — paid from the script’s own balance; detail is display text
labels readonly { pc, name }[] Label name per jump-destination pc

lookupPc(map, pc) binary-searches the segments and returns { note?: string }, or undefined when no segment covers the pc. The sites table powers explainRevert and the EvsDecodeError(site) round trip.

A detail names the site’s operands so that sites of the same kind can be told apart: an argument as args.<name> (args.arg0 for an unnamed one), a literal by its value, a call result by its call (s.read(totalSupply)), and any other value as #id, the id its handle prints (console.log(x) shows Expr<uint256> #12). A checked operation whose check can never fire (a division by a nonzero literal, a multiplication by a literal 0 or 1, a widening conversion, …) is a 'stmt' site, never a panic candidate.

Decodes a raw revert payload (the hex data from a failed eth_call) against this script’s IR and source map:

import { compile, evscript, t } from '@maxencerb/evs';
const script = evscript({ name: 'addOne', args: [t.uint256] }, (s, x) => {
return s.return({ y: s.add(x, 1n) });
});
const compiled = compile(script);
const explanation = compiled.explainRevert(
'0x4e487b710000000000000000000000000000000000000000000000000000000000000011',
);
explanation.kind; // 'panic'
explanation.panicCode; // 17n (0x11 — arithmetic overflow or underflow)
explanation.message; // quoted below

Output (the candidate names the s.add site by what it is, its operands and its site id):

Panic(0x11): arithmetic overflow or underflow — 1 candidate site(s) in this script: checked add args.arg0 + 1 (uint256) — Panic 0x11 (site 1)

The result shape (site ids are plain numbers):

interface RevertExplanation {
kind:
| 'panic'
| 'evs-decode'
| 'evs-invalid-calldata'
| 'error-string'
| 'script-error'
| 'custom'
| 'empty';
message: string;
panicCode?: bigint; // kind 'panic' only
errorName?: string; // kind 'script-error' only: the declared error's name
errorArgs?: Readonly<Record<string, unknown>>; // kind 'script-error' only: name-keyed decoded args
site?: { id: number; detail: string }; // kind 'evs-decode' only
candidateSites?: readonly { id: number; detail: string }[]; // 'panic', 'error-string', 'custom', 'empty', malformed 'script-error'
raw: `0x${string}`;
}
kind Payload Extras
'panic' Panic(uint256) (selector 0x4e487b71, 36 bytes) panicCode, plus candidateSites — every site in this script whose code can raise that panic code; with none, the message names the strict call sites it was bubbled through
'evs-decode' EvsDecodeError(uint256) site only for the exact 36-byte payload this compiler emits whose id resolves to a returndata-decode site in this artifact’s source map; a longer payload that still decodes is 'evs-decode' with no site. For a sendsValue site of a strict s.simulate, or of an s.call with revertReturns, the message adds that the script’s balance may have been below the value (an unfunded script fails those sites this way; a plain strict s.call reverts empty instead)
'evs-invalid-calldata' EvsInvalidCalldata() (exactly 4 bytes) Message names the expected function signature
'script-error' A custom error declared in the script’s errors errorName and errorArgs (name-keyed decoded args) — thrown by this script’s s.throw; when the args do not decode, no errorArgs and candidateSites as for 'error-string' (s.throw cannot produce it)
'error-string' Error(string) Bubbled verbatim from a callee; message quotes the reason; candidateSites — the strict call sites it can have come through
'custom' Any other selector (or a payload shorter than 4 bytes) Decode it against the callee’s ABI; candidateSites as for 'error-string'
'empty' Zero-length returndata A bare revert()/require(false) bubbled (candidateSites as for 'error-string'), a strict s.call whose value the script’s balance could not cover (the message names those sendsValue sites), or the frame failed without a reason: out of gas, or an opcode the node does not support at that block — the message names the fork-gated opcodes (PUSH0, MCOPY) the runtime uses

A callee’s revert can only enter the script through a strict call site — s.read, s.call or s.simulate, which bubble it verbatim. try* sites swallow it, and a revertReturns site decodes it as its value, so neither is ever listed. When a script has no strict call site, a payload only a callee can produce did not come from this artifact, and the message says so. explainRevert and decodeScriptError classify a payload with the same rules, so they always agree on what it is.

The panic-code meaning table (including codes evs itself never emits but callees can bubble) is in diagnostics.

Two free functions decode a revert against a script’s ABI on the client — typed over the script’s declared errors. Both take the script or the compiled artifact (anything with an abi) and either a caught viem error or raw revert hex:

declare function decodeScriptError<const abi extends Abi | readonly unknown[]>(
script: { readonly abi: abi },
input: unknown, // a caught (viem) error or 0x-hex revert data
): DecodedScriptError<abi> | undefined; // undefined when the input is not revert-shaped
declare function matchScriptError<
const abi extends Abi | readonly unknown[],
const handlers extends ScriptErrorHandlers<abi, unknown>,
>(script: { readonly abi: abi }, error: unknown, handlers: handlers): HandlerResult<handlers>;
// HandlerResult (exported): the union of the handlers' return types
Type Meaning
DecodedScriptError<abi> A name-discriminated union: one arm per ABI error entry ({ name, args, raw } — the declared errors and the two evs built-ins), plus DecodedBuiltinError
DecodedBuiltinError The built-in arms: Panic (code, meaning), Error (reason), unknown (selector — e.g. a callee error bubbled verbatim) and empty
ErrorArgsOf<e> One error entry’s decoded args, as a name-keyed record
HandlerResult<handlers> The union of the handlers’ return types — what matchScriptError returns
ScriptErrorHandlers<abi, r> The matchScriptError handler record: one handler per declared error (receives the typed args and the decoded arm) plus a required _ default for everything else

matchScriptError is exhaustive over the declared set — adding an error to the script without a handler is a type error — and rethrows a value that carries no revert data (a network error, a timeout) instead of routing it to _. The walkthrough is in errors and debugging.

compiled.ir is the frozen, JSON-serializable ScriptIr produced at recording — the same object as script.ir. Two free functions round-trip it:

import { compile, deserializeIr, evscript, serializeIr, t } from '@maxencerb/evs';
const script = evscript({ name: 'one', args: [] }, (s) => {
return s.return({ v: s.lit(t.uint256, 1n) });
});
const json = serializeIr(script.ir); // stable JSON: sorted keys, optional fields omitted
const ir = deserializeIr(json); // structural shape + version check; deep-frozen result
ir.irVersion; // 1
serializeIr(ir) === json; // true — the round trip is byte-stable
compile(script).ir === script.ir; // true — the artifact carries the same IR object

The bytecode itself is lowered from eliminateDeadCode(script.ir) — the pass compile() runs on every compile (see How it works). It is exported (alias dce) so you can inspect exactly what was compiled: same tables, same site ids, dead statements gone, and a fixpoint — applying it twice changes nothing. interpret(script.ir, …) applies it too (opt out with { dce: false }), so a dropped revert guard does not revert under the interpreter either.

import { compile, eliminateDeadCode, evscript, serializeIr, t } from '@maxencerb/evs';
const script = evscript({ name: 'guarded', args: [t.uint256, t.uint256] }, (s, a, b) => {
const unused = a.mul(b); // never read — dropped, its overflow check with it
void unused;
return s.return({ sum: a.add(b) });
});
const compiled = compile(script);
const lowered = eliminateDeadCode(compiled.ir); // what the bytecode was lowered from
lowered.values === compiled.ir.values; // true — the value table is never renumbered
serializeIr(eliminateDeadCode(lowered)) === serializeIr(lowered); // true — idempotent

Stability notes:

  • The format is versioned: irVersion: 1. deserializeIr rejects any other version, and the compiler emits only version 1.
  • serializeIr emits object keys sorted and omits undefined-valued optional properties, so two structurally equal IRs serialize to the same string.
  • deserializeIr performs the structural check only and throws EvsTypeError naming the offending JSON path; compile and interpret re-validate the IR semantically on entry, so externally sourced IR fails loudly rather than miscompiling.

The IR is directly executable by the exported reference interpreter: interpret(script, args, chain, opts?) runs a script against a MockChain (a staticcall({ to, data }) hook, plus an optional call({ to, data, kind, gas?, value? }) oracle for the s.call/s.simulate verbs) and returns an InterpResult whose returndata and revert payloads are bit-for-bit what the compiled bytecode produces: it runs eliminateDeadCode(ir) like compile() unless opts.dce is false. Passing the script (or this artifact) types args and the decoded values from the literal ABI, exactly like readContract; interpret(ir, …) takes a bare ScriptIr (e.g. from deserializeIr), untyped. InterpEnvOverrides (the opts.env extension) models frame-dependent s.env values per call. See testing scripts for the full workflow.

compiled.options is the frozen, fully resolved Readonly<Required<CompileOptions>> the artifact was built with — defaults applied: evmVersion: 'cancun', optimize: false, identity peephole, no-op onDiagnostic. See evscript for the option-by-option reference and EVM targets for choosing evmVersion.