Skip to content

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_call code parameter. Plain two-parameter eth_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_call parameter, 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.

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.

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.

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 back

toViem({ 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.

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 / 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).

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 through onDiagnostic about the result shapes that decide a limit on their own: DEPLOYLESS_RESULT_PREFIX for a static result whose first value is a bytesN, or a uint256/int256 that evs cannot bound away from the values that start with 0xEF (a hash or an id, a call output, an argument — not a length, a counter or s.env('blocknumber'); see which integers warn), and DEPLOYLESS_RESULT_SIZE for a result that is always over 24,576 bytes.
  • Before the call, deploylessDataSize(compiled, calldata) computes the payload size above, to compare with DEPLOYLESS_MAX_DATA_BYTES and 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.

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 MCOPY
const { supply: atBlock } = await client.readContract({
...compile(supply, { evmVersion: 'paris' }).toViem(),
functionName: 'supply',
args: ['0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'],
blockNumber: 16_000_000n,
});

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 ** 18n

s.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().