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.
  • 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 { arg, compile, evscript, t } from '@maxencerb/evs';
import { createPublicClient, erc20Abi, http } from 'viem';
const tokenMeta = evscript({ name: 'tokenMeta', args: [arg('token', t.address)] }, (s) => {
const symbol = s.call({ address: s.args.token, abi: erc20Abi, functionName: 'symbol' });
const decimals = s.call({ address: s.args.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.

import { arg, compile, evscript, t } from '@maxencerb/evs';
import { createPublicClient, erc20Abi, http } from 'viem';
const myBalance = evscript({ name: 'myBalance', args: [arg('token', t.address)] }, (s) => {
const balance = s.call({
address: s.args.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 two things deployless mode cannot do: a stable, controllable address(this), and a controllable msg.sender via the account call parameter.

Deployless (default)State override
RPC requirementplain two-parameter eth_call — every providerprovider must support the third eth_call parameter
address(this)counterfactual CREATE2 address, different per compiled scriptDEFAULT_SCRIPT_ADDRESS, or the address you pass
msg.senderviem’s internal wrapper contract — not controllablethe account call parameter (zero address if omitted)
Extra overridescomposes with additional stateOverride entries
Reach for it whenalways, unless you need the column to the rightcaller-relative reads, stable script address, spoofed state

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; there is no script-side configuration involved.

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(...)ResultDeploylessState override
'caller'Expr<'address'>viem’s wrapper contract (0xBd770416a3345F91E4B34576cb804a576fa48EB1 when no account is passed) — never your accountthe account call parameter (zero address if omitted)
'address'Expr<'address'>a per-script counterfactual CREATE2 addressthe override address
'timestamp'Expr<'uint256'>block timestamp — identical in both modesidentical
'blocknumber'Expr<'uint256'>block number — identicalidentical
'chainid'Expr<'uint256'>chain id — identicalidentical

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.

Because compile time cannot know which mode you will pick, compile() flags every s.env('caller') / s.env('address') with an ENV_FRAME_DEPENDENT warning through the onDiagnostic channel (never logged):

import { arg, compile, evscript, t, type EvsDiagnostic } from '@maxencerb/evs';
import { erc20Abi } from 'viem';
const myBalance = evscript({ name: 'myBalance', args: [arg('token', t.address)] }, (s) => {
const balance = s.call({
address: s.args.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().