Executing scripts
A compiled script is bytecode that runs inside a single eth_call — nothing is ever deployed.
The artifact’s toViem() method packages everything readContract needs, in one of two
execution modes:
- Deployless (the default): viem sends the script’s init bytecode as the
eth_callcodeparameter. Plain two-parametereth_call, works on every provider, but runs as a contract creation, which caps the result and payload size and adds gas — see deployless limits. - State override: viem places the script’s runtime bytecode at a chosen address via the
third
eth_callparameter, then calls that address.
Both shapes spread directly into readContract, and the artifact’s literal-typed ABI gives you
full inference on args and the result either way.
Deployless mode (default)
Section titled “Deployless mode (default)”import { compile, evscript, t } from '@maxencerb/evs';import { createPublicClient, erc20Abi, http } from 'viem';
const tokenMeta = evscript({ name: 'tokenMeta', args: [t.address] }, (s, token) => { const symbol = s.read({ address: token, abi: erc20Abi, functionName: 'symbol' }); const decimals = s.read({ address: token, abi: erc20Abi, functionName: 'decimals' }); return s.return({ symbol, decimals });});
const compiled = compile(tokenMeta);
const client = createPublicClient({ transport: http('https://eth.merkle.io') });
const result = await client.readContract({ ...compiled.toViem(), // { abi, code: initBytecode } functionName: 'tokenMeta', args: ['0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'],});// result: { symbol: string; decimals: number }toViem() with no arguments (or toViem({ mode: 'deployless' })) returns { abi, code },
where code is the artifact’s initBytecode. viem omits to from the eth_call, so the node
treats the data as a contract-creation frame: viem’s wrapper deploys your script inside the
simulated call (via CREATE2), calls it, and returns the result. No state-override support is
required from the provider, which makes this the maximally portable mode. The price is that the
node’s contract-creation rules apply to the whole call, result included — see
deployless limits.
State-override mode
Section titled “State-override mode”import { compile, evscript, t } from '@maxencerb/evs';import { createPublicClient, erc20Abi, http } from 'viem';
const myBalance = evscript({ name: 'myBalance', args: [t.address] }, (s, token) => { const balance = s.read({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [s.env('caller')], // msg.sender — controllable only in this mode }); return s.return({ balance });});
const compiled = compile(myBalance);const client = createPublicClient({ transport: http('https://eth.merkle.io') });
const result = await client.readContract({ ...compiled.toViem({ mode: 'stateOverride' }), // { abi, address, stateOverride } functionName: 'myBalance', args: ['0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'], account: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', // msg.sender seen by the script blockNumber: 22_000_000n, // pinned read — it is just eth_call});// result: { balance: bigint }toViem({ mode: 'stateOverride', address? }) returns { abi, address, stateOverride }: the
script’s runtimeBytecode is injected at address through the third eth_call parameter, and
the call targets that address like a normal contract read. The default address is the exported
DEFAULT_SCRIPT_ADDRESS (0xcD360FfAC9818c4396Aa6F4807EBfA72C4B3f530 — the last 20 bytes of
keccak256("evs.script"), with no code, storage, or balance on any major chain). Pass
address to relocate the script per call.
This mode requires a provider that supports the state-override eth_call extension, and it
unlocks what deployless mode cannot do: a stable, controllable address(this), a controllable
msg.sender via the account call parameter, and a script balance — add balance to the override
entry to fund a script that sends ETH.
Sender mode
Section titled “Sender mode”Sub-call targets (s.read, s.call, s.simulate and their try* variants) see
msg.sender = the address the script runs at — the account parameter only changes the
script’s own caller. No EVM primitive can alter that inside a frame, so for msg.sender-sensitive
targets (an ERC-4626 withdraw that burns the caller’s shares, onlyOwner views, permit-style
checks) state-override mode takes a sender option that installs the script at the sender’s
address and self-calls through it:
import { compile, evscript, t } from '@maxencerb/evs';import { createPublicClient, erc4626Abi, http } from 'viem';
const previewWithdraw = evscript( { name: 'previewWithdraw', args: [t.address, t.uint256] }, (s, vault, assets) => { // withdraw() checks and burns msg.sender's shares — it only succeeds AS the owner const shares = s.simulate({ address: vault, abi: erc4626Abi, functionName: 'withdraw', args: [assets, s.env('caller'), s.env('caller')], }); return s.return({ shares }); },);
const compiled = compile(previewWithdraw);const client = createPublicClient({ transport: http('https://eth.merkle.io') });
// The sender must OWN the shares: an address without enough sDAI makes withdraw() revert// (SavingsDai/insufficient-balance). This one held sDAI at the time of writing — use your own.const owner = '0x1715a3E4A142d8b698131108995174F37aEBA10D';
const result = await client.readContract({ ...compiled.toViem({ mode: 'stateOverride', sender: owner }), // → { abi, address: sender, stateOverride: [{ address: sender, code }], account: sender } functionName: 'previewWithdraw', args: ['0x83F20F44975D03b1b09e64809B757c47f942BEeA', 1_000_000n], // sDAI, 1e6 wei of DAI});// result: { shares: bigint } — withdraw() ran with msg.sender = owner, then rolled backtoViem({ mode: 'stateOverride', sender }) returns { abi, address, stateOverride, account } with
address and account both set to sender: the script lives at the sender’s address and is
called by the sender, so every sub-call target sees msg.sender = sender, and
s.env('caller') / s.env('address') both read sender. Passing your own account after the
spread re-targets only the script’s outer caller; the targets keep seeing sender. sender and
address are the same knob: address may restate the sender (any casing), and passing both with
different values throws. A sender or address that is not a 20-byte 0x address throws
EvsTypeError (TYPE_MISMATCH) up front, before viem sees it.
Choosing a mode
Section titled “Choosing a mode”| Deployless (default) | State override | |
|---|---|---|
| RPC requirement | plain two-parameter eth_call — every provider |
provider must support the third eth_call parameter |
address(this) |
counterfactual CREATE2 address, different per compiled script | DEFAULT_SCRIPT_ADDRESS, or the address you pass |
msg.sender |
viem’s internal wrapper contract — not controllable | the account call parameter (zero address if omitted — but see the multicall note below) |
msg.sender seen by sub-call targets |
the counterfactual script address — not controllable | the script address, or a chosen sender in sender mode |
| Extra overrides | — | composes with additional stateOverride entries |
| Result size | at most 24,576 bytes, and must not start with byte 0xEF |
no protocol cap |
| Payload size | 398 + 128 + pad32(initBytecode) + pad32(encoded call) ≤ 49,152 bytes (details) |
no protocol cap |
| Gas on top of the script | about 75,000 + 220 per runtime byte + 200 per result byte | none |
| Reach for it when | always, unless you need the column to the right or hit a deployless limit | caller-relative reads, stable script address, spoofed state, msg.sender-sensitive sub-calls, large results, results that start with a hash or full-width integer, large args |
Provider support
Section titled “Provider support”| Provider / node | deployless (to: null) |
stateOverride (3rd eth_call param) |
|---|---|---|
| geth, reth, anvil | yes | yes (documented; anvil verified in the integration suite) |
| QuickNode, publicnode | yes | yes (documented / empirically verified) |
| Alchemy, Infura | yes | not documented for eth_call — probe before relying |
Deployless works everywhere eth_call works, which is why it is the default. Both modes run
under the node’s eth_call gas cap (geth’s default --rpc.gascap is 50M; hosted providers set
their own), the ceiling for scripts making many calls. Deployless spends part of that budget on
the contract creation, and the creation rules bound its result and payload, as described below.
To pick the mode per provider at run time, type it as ToViemMode and call
toViem({ mode }): the result is the union of both shapes and still spreads into
readContract (see the artifact reference).
Deployless limits
Section titled “Deployless limits”viem’s deployless call has no to, so the node runs it as a contract creation: viem’s
398-byte wrapper constructor deploys your script with CREATE2, calls it, and returns the
script’s result as the wrapper’s deployed code. The protocol’s creation rules therefore apply
to the call itself, and none of them apply to state-override mode, which installs the runtime
without deploying anything and returns the result as plain return data:
| Rule | What it caps in deployless mode | Node error (in viem’s details) |
|---|---|---|
| EIP-170 | the script’s ABI-encoded result: at most 24,576 bytes | max code size exceeded (geth), CreateContractSizeLimit (anvil) |
| EIP-3541 | the result’s first byte: it must not be 0xEF |
invalid code: must not begin with 0xef (geth), CreateContractStartingWithEF (anvil) |
| EIP-3860 | the payload: the wrapper, initBytecode and the encoded call (selector + args), ABI-framed, at most 49,152 bytes |
max initcode size exceeded (geth and anvil) |
The payload is 398 + 128 + pad32(initBytecode) + pad32(calldata) bytes, so the init bytecode
plus the encoded call must fit in about 48.6 KB: a small script can take roughly 1,500
uint256 (or address) array elements as args, and a script returning one uint256[] can
return at most 765 elements. The failure is always loud — the node rejects the call and viem
throws (usually as “Missing or invalid parameters” or “Transaction creation failed”, with the
rule in details); no wrong value is ever returned. evs helps on three fronts, all covered in
the artifact reference:
- At compile time,
compile()warns throughonDiagnosticabout the result shapes that decide a limit on their own:DEPLOYLESS_RESULT_PREFIXfor a static result whose first value is abytesN, or auint256/int256that evs cannot bound away from the values that start with0xEF(a hash or an id, a call output, an argument — not a length, a counter ors.env('blocknumber'); see which integers warn), andDEPLOYLESS_RESULT_SIZEfor a result that is always over 24,576 bytes. - Before the call,
deploylessDataSize(compiled, calldata)computes the payload size above, to compare withDEPLOYLESS_MAX_DATA_BYTESand pick the mode per call. - After a failure,
explainDeploylessError(error)recognizes the node’s creation error and says which rule failed ('result-starts-with-ef' | 'result-too-large' | 'data-too-large').
The size of a dynamic result depends on the data, so DEPLOYLESS_RESULT_SIZE only fires when
even the smallest encoding is over the cap; a uint256[] that grows with its args is caught by
the node, not by compile().
The creation also costs gas that state-override mode does not pay: about 75,000 gas fixed
(the creation itself, the CREATE2 and the wrapper), about 220 gas per byte of runtime
bytecode (mostly the 200 gas/byte code deposit for deploying it) and 200 gas per byte of result
(deposited as the wrapper’s code). A 5 KB script returning 2 KB spends about 1.6M gas on top of
its own work in deployless mode.
Block pinning
Section titled “Block pinning”Both modes are ordinary eth_calls, so viem’s block selectors pass straight through: give
readContract a blockNumber or a blockTag and the whole script — including every sub-call
it makes — executes against that block’s state. Historical reads work in either mode.
The pinned block also decides which opcodes exist, so the artifact must target the fork that
was active at that block: the default cancun target emits MCOPY (Cancun) and PUSH0
(Shanghai). On Ethereum mainnet, use cancun from block 19,426,587, shanghai from
17,034,870, and paris (the oldest target) before that, down to Constantinople at block
7,280,000: every build uses SHL/SHR and viem’s deployless wrapper uses CREATE2, all
Constantinople opcodes, and a script reading s.env('chainid') needs Istanbul (block
9,069,000) for CHAINID; no evs script runs on an older block. Other chains activated these
forks at their own heights. A mismatch fails as a bare “execution reverted” with no data in
deployless mode, and as the node’s invalid-opcode error (anvil: NotActivated) in
state-override mode.
See EVM targets.
import { compile, evscript, t } from '@maxencerb/evs';import { createPublicClient, erc20Abi, http } from 'viem';
const supply = evscript({ name: 'supply', args: [t.address] }, (s, token) => s.return({ supply: s.read({ address: token, abi: erc20Abi, functionName: 'totalSupply' }) }),);
const client = createPublicClient({ transport: http('https://eth.merkle.io') });
// block 16,000,000 predates Shanghai: no PUSH0, no MCOPYconst { supply: atBlock } = await client.readContract({ ...compile(supply, { evmVersion: 'paris' }).toViem(), functionName: 'supply', args: ['0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'], blockNumber: 16_000_000n,});s.env and frame dependence
Section titled “s.env and frame dependence”s.env(kind) reads the execution environment. Three of the five values are block context and
identical in both modes; two depend on the call frame toViem() produces:
s.env(...) |
Result | Deployless | State override |
|---|---|---|---|
'caller' |
Expr<'address'> |
viem’s wrapper contract (0xBd770416a3345F91E4B34576cb804a576fa48EB1 when no account is passed) — never your account |
the account call parameter (zero address if omitted; the Multicall3 contract on a batch.multicall client — see below) |
'address' |
Expr<'address'> |
a per-script counterfactual CREATE2 address | the override address |
'timestamp' |
Expr<'uint256'> |
block timestamp — identical in both modes | identical |
'blocknumber' |
Expr<'uint256'> |
block number (the NUMBER opcode; on Arbitrum an approximate L1 block, see below) — identical |
identical |
'chainid' |
Expr<'uint256'> |
chain id — identical | identical |
The deployless values exist because of how the mode works: viem’s wrapper contract performs the
CREATE2 and then calls your script, so the script sees the wrapper as msg.sender and its own
counterfactual deployment address as address(this). Neither is controllable, and there is no
deployless workaround. Caller-relative reads — balanceOf(s.env('caller')) and friends —
require toViem({ mode: 'stateOverride' }) plus the account call parameter.
The script’s own balance, s.balance(s.env('address')),
inherits the same split: deployless, it is the balance of the fresh counterfactual address
(normally zero); in state-override mode it is whatever the override address holds, which the
same override entry can set:
import { evscript } from '@maxencerb/evs';import { createPublicClient, http, parseEther } from 'viem';import { mainnet } from 'viem/chains';
const client = createPublicClient({ chain: mainnet, transport: http() });
const selfBalance = evscript({ name: 'selfBalance' }, (s) => s.return({ wei: s.balance(s.env('address')) }),).compile();
const params = selfBalance.toViem({ mode: 'stateOverride' });const { wei } = await client.readContract({ ...params, functionName: 'selfBalance', // the script entry keeps its code; `balance` funds the script address for this call stateOverride: params.stateOverride.map((o) => ({ ...o, balance: parseEther('1') })),});// wei === 10n ** 18ns.env('blocknumber') is Solidity’s block.number: the NUMBER opcode, which reports what the
chain defines. On Arbitrum that is an approximate L1 (Ethereum) block number, not the L2
block you pinned with blockNumber; read the L2 height from the ArbSys precompile instead:
import { evscript } from '@maxencerb/evs';import { parseAbi } from 'viem';
const arbSysAbi = parseAbi(['function arbBlockNumber() view returns (uint256)']);
const heights = evscript({ name: 'heights' }, (s) => s.return({ l1: s.env('blocknumber'), // on Arbitrum: an approximate L1 block number l2: s.read({ address: '0x0000000000000000000000000000000000000064', // ArbSys abi: arbSysAbi, functionName: 'arbBlockNumber', }), }),);Because compile time cannot know which mode you will pick, compile() flags every
s.env('caller') / s.env('address') — and every s.balance(s.env('address')) — with an
ENV_FRAME_DEPENDENT warning through the onDiagnostic channel (never logged):
import { compile, evscript, t, type EvsDiagnostic } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const myBalance = evscript({ name: 'myBalance', args: [t.address] }, (s, token) => { const balance = s.read({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [s.env('caller')], }); return s.return({ balance });});
const diagnostics: EvsDiagnostic[] = [];compile(myBalance, { onDiagnostic: (d) => diagnostics.push(d) });
// diagnostics[0].code → 'ENV_FRAME_DEPENDENT'// diagnostics[0].message → "s.env('caller') is execution-frame-dependent: in the default// deployless toViem() mode msg.sender is viem's internal wrapper contract — NOT the// eth_call `account`; caller-relative reads require toViem({ mode: 'stateOverride' })// plus the `account` call parameter"See errors and debugging for the rest of the diagnostics
channel, and testing scripts for modeling either frame off-chain
with interpret().