Calling contracts
Sub-calls reach another contract from inside your script. They come in three verbs, split by
the callee’s mutability and the call frame they run in. All three share the same parameter
object — deliberately shaped like viem’s readContract, except that address and every argument
can also be an Expr produced earlier in the script — and each has a try* companion returning
{ success, value }:
| Verb | Opcode | Mutability | State semantics | Use it for |
|---|---|---|---|---|
s.read / s.tryRead |
STATICCALL |
view / pure |
static — no state change possible | normal view reads |
s.call / s.tryCall |
CALL (value 0 unless you send ETH) |
nonpayable / payable |
a real non-static frame; the write is not rolled back — it persists to later sub-calls in the same eth_call (the eth_call itself never commits) |
non-view functions that need a real frame but don’t usefully keep state — the canonical case is a Uniswap quoter |
s.simulate / s.trySimulate |
CALL (value 0 unless you send ETH) via a self-call trampoline |
nonpayable / payable |
the write is rolled back and isolated from later reads, yet its return value is read back | dry-run a true write and read what it would return |
The verb you pick is enforced at the functionName type level: a nonpayable function under
s.read is a compile error (steered to s.call/s.simulate), and a view/pure function under
s.call/s.simulate is a compile error (steered to s.read). This holds even when the ABI has
no function at all in the verb’s bucket (an all-nonpayable ABI under s.read): with an
as const ABI the accepted names are then none. The runtime recorder mirrors this with a steering
EvsTypeError.
That data flow between calls is the whole point: outputs of one call feed the next call on-chain,
in a single eth_call.
s.read / s.tryRead
Section titled “s.read / s.tryRead”s.read reads a view/pure function from inside your script via STATICCALL:
import { evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const uniswapV3PoolAbi = [ { type: 'function', name: 'token0', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'address' }], },] as const;
const tokenInfo = evscript( { name: 'tokenInfo', args: [t.address, t.address] }, (s, pool, user) => { const token0 = s.read({ address: pool, abi: uniswapV3PoolAbi, functionName: 'token0' }); // ^? Expr<'address'> — feeds the next two calls on-chain const symbol = s.read({ address: token0, abi: erc20Abi, functionName: 'symbol' }); const balance = s.read({ address: token0, abi: erc20Abi, functionName: 'balanceOf', args: [user], }); return s.return({ token0, symbol, balance }); },);functionName autocompletes from the ABI, filtered to pure and view functions —
nonpayable/payable names are type errors at the functionName level (use s.call
or s.simulate for those). Each entry of args
independently accepts either the plain JS literal viem would take (bigint, number,
0x strings, …) or an Expr of that exact parameter type, so literals and runtime values mix
freely in one call. See values and types for the coercion rules.
Parameters
Section titled “Parameters”These keys are the same for all six verbs (only the functionName mutability filter changes),
except value, which only the four CALL verbs accept (for a payable function), and
revertReturns, which only s.call / s.tryCall accept. Any other key is an EvsTypeError at
recording, so a typo never silently does nothing. TypeScript also flags a key written in the
object literal itself (an excess-property error), but not one that arrives through a spread: a
viem/wagmi-style contract config carrying extra keys, such as
{ address, abi, chainId }, typechecks when spread into a call and then throws at recording.
Spread only the keys evs takes (const { address, abi } = usdc).
| Key | Type | Notes |
|---|---|---|
address |
IntoExpr<'address'> |
A 0x literal or an Expr<'address'> from an earlier statement |
abi |
Abi |
Inline or as const, exactly as with viem. Each function entry is validated and its selector computed once, then reused by every call site that names it: do not mutate an ABI entry after a script used it |
functionName |
name union | Restricted per verb — pure/view for s.read, nonpayable/payable for s.call/s.simulate |
args |
per-parameter literal-or-Expr tuple |
Omit for zero-arg functions |
gas |
IntoExpr<'uint256'> (optional) |
Gas cap for the target call — see capping a target’s gas; by default all available gas is forwarded |
value |
IntoExpr<'uint256'> (optional, payable functions under s.call / s.simulate and their try* forms) |
Wei sent with the call (msg.value), paid from the script’s own balance — see sending ETH; default 0 |
struct |
boolean (optional) |
Opt in to decode a function’s multiple named outputs into one named Tuple (ABI order) instead of the default positional tuple; every output must be named, each name once — see below |
revertReturns |
readonly EvsType[] (optional, s.call / s.tryCall only) |
The output types carried by the target’s revert data (the QuoterV1 pattern); replaces the ABI outputs as the decode schema — see decoding revert data |
Every s.read sub-call executes as a STATICCALL, so the callee runs in a read-only frame: even a
function mislabeled as view in its ABI cannot mutate state — an attempted write makes that
call fail. The script as a whole can never write state or deploy anything; s.call/s.simulate
make a real CALL frame but their effects are still confined to the eth_call and never committed
on-chain.
Return shapes
Section titled “Return shapes”s.read mirrors viem’s output unwrapping:
| ABI outputs | Recorded result |
|---|---|
| none | void |
| exactly one (scalar) | Expr of that type (unwrapped) |
exactly one (tuple) |
a Tuple handle (unwrapped) |
exactly one (tuple[]) |
an array Expr — .at(i) returns a Tuple element |
| several | readonly tuple of Expr/Tuple handles, in ABI order |
import { evscript, t } from '@maxencerb/evs';
const uniswapV3PoolAbi = [ { type: 'function', name: 'slot0', stateMutability: 'view', inputs: [], outputs: [ { name: 'sqrtPriceX96', type: 'uint160' }, { name: 'tick', type: 'int24' }, { name: 'observationIndex', type: 'uint16' }, { name: 'observationCardinality', type: 'uint16' }, { name: 'observationCardinalityNext', type: 'uint16' }, { name: 'feeProtocol', type: 'uint8' }, { name: 'unlocked', type: 'bool' }, ], },] as const;
const poolPrice = evscript({ name: 'poolPrice', args: [t.address] }, (s, pool) => { const slot0 = s.read({ address: pool, abi: uniswapV3PoolAbi, functionName: 'slot0' }); // ^? readonly [Expr<'uint160'>, Expr<'int24'>, Expr<'uint16'>, ...] return s.return({ sqrtPriceX96: slot0[0], tick: slot0[1], unlocked: slot0[6] });});Here slot0 returns seven flat outputs, so it unwraps to a readonly tuple of Exprs
accessed positionally. A getter that returns a single Solidity struct (one 'tuple' output)
instead unwraps to a Tuple handle with named field access — see
struct arguments and outputs below.
Decoding multiple outputs into a struct
Section titled “Decoding multiple outputs into a struct”For a function with multiple named outputs like slot0, the positional tuple is the default,
but passing struct: true instead composes all the outputs into one named
Tuple handle (in ABI declaration order), so you read them by name:
import { evscript, t } from '@maxencerb/evs';
const uniswapV3PoolAbi = [ { type: 'function', name: 'slot0', stateMutability: 'view', inputs: [], outputs: [ { name: 'sqrtPriceX96', type: 'uint160' }, { name: 'tick', type: 'int24' }, { name: 'unlocked', type: 'bool' }, ], },] as const;
const poolPrice = evscript({ name: 'poolPrice', args: [t.address] }, (s, pool) => { const slot0 = s.read({ address: pool, abi: uniswapV3PoolAbi, functionName: 'slot0', struct: true }); // ^? Tuple<{ sqrtPriceX96: 'uint160'; tick: 'int24'; unlocked: 'bool' }> — named, ABI order return s.return({ sqrtPriceX96: slot0.sqrtPriceX96.get(), // Expr<'uint160'>, by name tick: slot0.tick.get(), // Expr<'int24'> slot0, // the whole struct flows out as one object });});Every output must be named, and no two outputs may share a name — an unnamed output throws
EvsTypeError at recording (an unnamed component would silently degrade viem’s result to a
positional array), and a repeated name throws EvsTypeError with code ABI_SHAPE (viem’s decoded
object keeps one value per name, so the other would be lost). Because the struct is in ABI
order, it unifies with a t.struct declared in the same order and with
t.fromOutputs(abi, name). s.tryRead({ …, struct: true }) mirrors this on its .value (and so do s.call/s.simulate and their try*
variants). The default (struct omitted) positional shape is unchanged; a non-literal boolean
widens the result to the union of both shapes, so the caller must narrow.
The struct is not free: it is a fresh block of one word per output, allocated after the outputs
are decoded. Inside a loop that is one more allocation per iteration, reported as a
LOOP_ALLOCATION
diagnostic of its own on top of whatever the call itself allocates. In a hot loop, read the
outputs positionally instead.
A few typing behaviors carried over from viem, all enforced at recording time rather than as hard type errors where viem is also permissive:
- Graceful widening — an ABI without
as const(typedAbi, imported from JSON) degrades tofunctionName: stringandargs: readonly unknown[]instead of erroring. The types cannot see the output count, so the result is typed as every shape it can take at run time:Expr | Tuple | readonly (Expr | Tuple)[] | undefined—undefinedfor no output, a single handle for one, a readonly array for several. Cast it to the shape the function has (or declare the ABIas const) before use;struct: trueis always oneTuple. See widened ABIs below. - Overloaded names resolve by the
args— see overloaded functions below. - Every array shape up to four levels deep is supported in arguments and outputs —
structs/tuples, arrays of structs (
tuple[],tuple[][]), nested arrays (uint256[][][]), dynamic-leaf arrays (string[],bytes[][]), and fixed-size arrays (uint256[2],Position[3],string[2]) — see struct and array arguments and outputs. A deeper array (uint256[][][][][]) or a static type of 2^32 bytes or more (uint256[100000000][100000000], see values and types) throwsEvsTypeError(UNSUPPORTED_V0) at recording and a malformed type (uint256[0],tuple[0])TYPE_MISMATCH/ABI_SHAPE, naming the parameter.
Widened ABIs
Section titled “Widened ABIs”The result of a call on a widened ABI is exported as WideSubcallResult. It is neither iterable nor
numeric, so narrow it with a cast to the shape the function really has: a bare Expr is not enough
for arithmetic or toUint/toInt, which need a numeric type.
import { evscript, t, type Expr } from '@maxencerb/evs';import type { Abi } from 'viem';
declare const poolAbi: Abi; // e.g. imported from a JSON artifactdeclare const tokenAbi: Abi;
const widePool = evscript({ name: 'widePool', args: [t.address, t.address] }, (s, pool, token) => { // slot0 has three outputs: a readonly array at run time const [sqrtPriceX96, tick] = s.read({ address: pool, abi: poolAbi, functionName: 'slot0' }) as readonly [ Expr<'uint160'>, Expr<'int24'>, Expr<'bool'>, ]; // decimals has one output: the handle itself const decimals = s.read({ address: token, abi: tokenAbi, functionName: 'decimals' }) as Expr<'uint8'>; return s.return({ sqrtPriceX96, tick, decimals: decimals.toUint(t.uint256) });});Nothing checks the cast against the ABI, so it must match the function’s real outputs. Declaring
the ABI as const gives exact types with no cast.
Overloaded functions
Section titled “Overloaded functions”When an ABI declares several functions with the same name (Solidity overloads), a bare
functionName is resolved by the types of the args — the same approach as viem’s
readContract — and the result is typed from the overload that was picked. Only overloads in the
verb’s mutability bucket compete: a nonpayable overload is never a candidate under s.read, and
a view one never under s.call/s.simulate.
import { evscript, t } from '@maxencerb/evs';
const vaultAbi = [ { type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }], outputs: [{ name: '', type: 'uint256' }], }, { type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [ { name: 'owner', type: 'address' }, { name: 'id', type: 'uint256' }, ], outputs: [{ name: '', type: 'uint128' }], }, { type: 'function', name: 'price', stateMutability: 'view', inputs: [{ name: 'id', type: 'uint256' }], outputs: [{ name: '', type: 'uint256' }], }, { type: 'function', name: 'price', stateMutability: 'view', inputs: [{ name: 'decimals', type: 'uint8' }], outputs: [{ name: '', type: 'int256' }], },] as const;
const balances = evscript( { name: 'balances', args: [t.address, t.address, t.uint256] }, (s, vault, owner, id) => { const total = s.read({ address: vault, abi: vaultAbi, functionName: 'balanceOf', args: [owner] }); // ^? Expr<'uint256'> — balanceOf(address) const ofId = s.read({ address: vault, abi: vaultAbi, functionName: 'balanceOf', args: [owner, id] }); // ^? Expr<'uint128'> — balanceOf(address,uint256) const byId = s.read({ address: vault, abi: vaultAbi, functionName: 'price', args: [id] }); // ^? Expr<'uint256'> — the Expr<'uint256'> handle picks price(uint256) const scaled = s.read({ address: vault, abi: vaultAbi, functionName: 'price(uint8)', args: [18] }); // ^? Expr<'int256'> — `18` fits both price overloads: name one by signature return s.return({ total, ofId, byId, scaled }); },);
void balances;The rules, applied identically by the types and at recording:
- Arity first: when a single overload takes that many arguments, it is chosen and the
arguments are then checked against it as for any call. Otherwise each argument must fit:
- a handle (
Expr,Tuple,MutArray) must carry exactly the parameter’s type: aMutArrayfroms.newArray(t.uint256, n)is auint256[](not anaddress[], nor a struct array), one froms.newArray(t.uint256, 2, { fixed: true })auint256[2], and a struct handle must have the same members with the same types (the types compare a struct’s members by name, since at.struct’s key order is not visible to them; the recorder also checks the order); - a literal only needs the right JS kind:
number/bigintfor everyuintN/intN, a0x…string foraddress/bytesN/bytes, any string forstring, a boolean forbool, a name-keyed object for a struct (an array for an unnamed tuple), and an array for an array type, whose elements (literals or handles, e.g.[x, 2n]) must fit the element type, at any depth. A fixed-sizeT[N]takes exactly N elements, so[1n, 2n, 3n]fitsuint256[]but notuint256[2], and5nfitsuint256but notuint256[2]. A struct literal fits only if every key names a member (an unnamed tuple’s array: exactly its length), so{ a: 1n, b: 2n }fits(uint256 a, uint256 b)but not(uint256 a). Value ranges and byte lengths are not considered, so1nfits bothuint8anduint256.
- a handle (
- Exactly one overload must fit. Arguments that fit several are a compile error
(
'evs: ambiguous overload') and, when types are bypassed, anEvsTypeErrorwith codeABI_SHAPEat recording naming the candidates; arguments that fit none are a compile error too ('evs: no overload matches') and aTYPE_MISMATCHat recording. - To settle an ambiguity, type the literal (
s.lit(t.uint8, 18)) or pass the overload’s canonical signature asfunctionName: the name, then the parameter types without names or spaces, tuples written as their component list —'price(uint8)','submit((uint256,address)[])'. A signature selects exactly that entry (argument-based resolution is skipped), is offered in thefunctionNameautocomplete, and works for non-overloaded functions too. With anas constABI the signature must be spelled exactly in canonical form:'price(uint8, address)'(with a space) is a compile error. Only the recorder tolerates spaces, i.e. with a non-constABI or when the types are bypassed.
t.fromOutputs(abi, name) has no arguments to resolve by, so for an overloaded function it takes
the signature (t.fromOutputs(vaultAbi, 'price(uint8)')).
Struct and array arguments and outputs
Section titled “Struct and array arguments and outputs”When a view function takes or returns a Solidity struct (an ABI 'tuple' parameter), evs
handles it as a composite value — see composite types
for the full model. The same handling applies to s.call/s.simulate. Two cases:
A struct output decodes into a Tuple handle — read members by
name with .field.get(), or return the whole struct by handing the handle back directly (.expr()
also works, for the bare memref Expr):
import { evscript, t } from '@maxencerb/evs';
const positionsAbi = [ { type: 'function', name: 'positions', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [ { name: '', type: 'tuple', components: [ { name: 'nonce', type: 'uint96' }, { name: 'operator', type: 'address' }, { name: 'liquidity', type: 'uint128' }, ], }, ], },] as const;
const positionInfo = evscript( { name: 'positionInfo', args: [t.address, t.uint256] }, (s, manager, tokenId) => { const pos = s.read({ address: manager, abi: positionsAbi, functionName: 'positions', args: [tokenId] }); // ^? Tuple<…> return s.return({ operator: pos.operator.get(), liquidity: pos.liquidity.get(), position: pos }); },);A struct argument accepts a Tuple handle, a s.tuple(...) result, or an object literal.
The literal names every member, and each member may be a constant or a runtime value (an Expr,
or a Tuple handle / nested literal for a struct member); it records the same struct as the
s.tuple form. Reach for s.tuple to leave members at zero or to
build the struct once and pass it to several calls:
import { evscript } from '@maxencerb/evs';
// Declaring the tuple type as a raw `as const` descriptor matches the ABI's component order// exactly — `s.tuple` and a raw `readonly AbiParameter[]` are accepted anywhere a tuple type is.const SwapParams = { type: 'tuple', components: [ { name: 'tokenIn', type: 'address' }, { name: 'fee', type: 'uint24' }, { name: 'amountIn', type: 'uint256' }, ],} as const;
const poolStateAbi = [ { type: 'function', name: 'previewSwap', stateMutability: 'view', inputs: [{ name: 'p', ...SwapParams }], outputs: [{ name: 'amountOut', type: 'uint256' }], },] as const;
const previewSwap = evscript( { name: 'previewSwap', args: ['address', 'address', 'uint24', 'uint256'] }, (s, pool, tokenIn, fee, amountIn) => { const params = s.tuple(SwapParams, { tokenIn, fee, amountIn }); const amountOut = s.read({ address: pool, abi: poolStateAbi, functionName: 'previewSwap', args: [params] }); // the same call with an object literal: same calldata const sameOut = s.read({ address: pool, abi: poolStateAbi, functionName: 'previewSwap', args: [{ tokenIn, fee, amountIn }], }); return s.return({ amountOut, sameOut }); },);A TupleType script argument arrives the same way as a decoded output — as a Tuple handle —
so a struct can flow straight from calldata into a sub-call.
Arrays of structs (tuple[]) work the same way. A 'tuple[]' output is an array handle —
.at(i) returns the i-th element as a Tuple you read by field name; a 'tuple[]' argument
accepts an array handle (arr.expr()) or an array literal whose elements are struct literals
(runtime members allowed) or Tuple handles. One-level nested
arrays (uint256[][]) and dynamic-leaf arrays (string[]/bytes[]) are supported in the same
positions. See composite arrays
for read/build/pass examples.
import { evscript, t } from '@maxencerb/evs';
const registryAbi = [ { type: 'function', name: 'positionsOf', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }], outputs: [ { name: '', type: 'tuple[]', components: [ { name: 'liquidity', type: 'uint128' }, { name: 'tickLower', type: 'int24' }, ], }, ], },] as const;
const firstPosition = evscript( { name: 'firstPosition', args: [t.address, t.address] }, (s, registry, owner) => { const positions = s.read({ address: registry, abi: registryAbi, functionName: 'positionsOf', args: [owner], }); // ^? Expr<'tuple[]'> — the array handle const first = positions.at(0n); // Tuple element — Panic(0x32) if empty return s.return({ liquidity: first.liquidity.get(), positions }); },);Revert behavior: verbatim bubbling
Section titled “Revert behavior: verbatim bubbling”When a callee reverts under s.read (and likewise s.call/s.simulate), the entire script
reverts with the callee’s revert data, byte-exact — Error(string), Panic(uint256), and
custom errors alike. Your viem call site sees exactly the error it would have seen calling the
contract directly.
Two failure modes are distinguished:
- The callee reverted — its revert data bubbles verbatim out of the script.
- The call succeeded but the returndata does not decode against the ABI (too short,
out-of-bounds offsets, a length word the returndata cannot back, a nested struct whose head is
cut short, a non-contract address returning nothing, offsets that exhaust the
decode-work budget) — the script reverts with
the
EvsDecodeError(site)custom error. This error is part of the script’s own ABI, so viem names it, andexplainRevertmaps the site id back to the offending call. See errors and debugging.
Dirty bits in word-typed outputs are normalized, not reverted: a uintN, address or
bytesN output is masked to its declared width, an intN output is sign-extended from its
width, and any nonzero bool word reads as true. A uint8 output whose word holds 300 comes
back as 44, and an int8 word of 0x1f6 as −10. This is how Solidity’s legacy ABI coder v1
cleans returndata; coder v2, the default since 0.8, reverts instead. viem’s decoder sits in
between: it masks address and bytesN the same way, but returns a uintN/intN word unmasked
(300 stays 300) and throws on a bool word other than 0 or 1, so a misbehaving callee can read differently through evs and through a plain
readContract. Every output is decoded from the original returndata, so even non-canonical data
whose offsets make two outputs share bytes decodes each of them exactly as if it were alone,
within one shared decode-work budget: the
outputs of a call draw on the same budget, so several outputs aliasing one large charged tail (a
uint8[][], a struct array) are charged once each and can exhaust it together.
Overlapping offsets and the decode-work budget
Section titled “Overlapping offsets and the decode-work budget”The ABI encodes an array of dynamic elements (uint256[][], string[], a struct array) as a
list of offsets, and nothing in the format stops a hostile callee from pointing every offset at
the same element. Decoding such data naively costs far more than its size: 600 offsets at
one 600-element uint8[] is 38 KB of returndata but 600 copies of the inner array, and 1500
offsets at one 101-field struct is 51 KB but 1500 copies of the struct, enough to run the whole
eth_call out of gas from inside a try* verb (the cost is paid in the script’s own frame
after the callee returned, so the call’s gas cap does not help). evs bounds that work two
ways:
-
Full-word inner arrays are never copied. A
uint256[],int256[]orbytes32[]needs no normalization, so wherever it sits (an output, a struct member, an element of an outer array) it points straight into the returndata. Overlapping offsets at oneuint256[]cost one bounds check each, and the data decodes. -
Everything else draws on a decode-work budget. Each block the decoder builds is charged the returndata bytes it mirrors, out of a per-call budget of the returndata size plus 8192 words (256 KiB):
- a dynamic array (
uint8[],address[], … normalized into a fresh copy, or an array of arrays, strings or structs): its length word plus its body,32 + length × element size(32 bytes per element, or the full size of a static struct / staticT[N]element); - inside an element of an array, where offsets can make it decode many times: a struct with
a dynamic member, charged its head (32 bytes per dynamic member plus the size of every
static one), and a fixed-size
T[N]of dynamic elements, charged itsNoffset words.
Static structs and static
T[N]are charged with the block that holds them. A well-formed encoding is never charged more than its own size, so only overlapping offsets can exhaust the budget; when they do, the call fails like any other malformed returndata:success = falseunders.tryRead/s.tryCall/s.trySimulate,EvsDecodeError(site)under the strict verbs. A word array that is itself one of the call’s outputs, and structs andT[N]outside every array, are not charged: a call decodes them a fixed number of times. Every block’s memory is within a factor fixed by its type of what it is charged, so decode work stays linear in the returndata size.A call whose outputs hold a single charged block, decoded once (a lone
string[],bytes[],uint256[][]or static-struct array output such as(uint256,address)[], or a struct output holding exactly one such array, or one normalized word array such as theuint64[]of(uint64[])), compiles without the budget: like a word-array output, that block is decoded exactly once, and it lies inside the returndata, so its one charge can never exceed the returndata size, let alone the budget. Such a call site is about 40 to 55 bytes smaller (thetry*verbs save the most) and about 100 gas cheaper. - a dynamic array (
The 8192-word slack matches viem’s decoder, which throws RecursiveReadLimitExceededError once
it has re-read already-visited positions 8192 times (its default recursiveReadLimit): for
overlapping uint8[][] returndata evs and viem give up at the same amount of overlap, to within
one word. Script arguments are
your own calldata and are not budgeted. The interpreter applies the
same budget, so it agrees with the compiled script on every payload.
s.tryRead
Section titled “s.tryRead”s.tryRead takes the exact same parameter object as s.read and never reverts the script on
callee failure. It records { success, value }:
import { evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const tokenDecimals = evscript({ name: 'tokenDecimals', args: [t.address] }, (s, token) => { const d = s.tryRead({ address: token, abi: erc20Abi, functionName: 'decimals' }); // d.success: Expr<'bool'> d.value: Expr<'uint8'> — 0 when the call failed return s.return({ decimals: s.select(d.success, d.value, 18) });});successisExpr<'bool'>— false when the call failed or when the returndata is structurally malformed. Both cases fold into the same flag.valuehas the same shapes.readwould produce (single output unwrapped, several as a tuple). On failure every output is a safe default: zero for numeric and word types,falseforbool, the zero address, emptystring/bytes, empty arrays.valueis always safe to use — guard withsuccess(ors.select) when zero is a valid real-world reading.
import { evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const cappedSymbol = evscript({ name: 'cappedSymbol', args: [t.address] }, (s, token) => { const r = s.tryRead({ address: token, abi: erc20Abi, functionName: 'symbol', gas: 200_000n, // cap; without it the sub-call forwards all available gas }); return s.return({ ok: r.success, symbol: r.value }); // symbol is '' when the call failed});The gas cap pairs naturally with s.tryRead: it bounds how much a misbehaving callee can
burn before the script moves on. Decoding what the callee returned happens in the script’s own
frame, outside that cap; the decode-work budget
keeps that part linear in the returndata size (with a factor fixed by the output types).
Mutable calls (s.call / s.tryCall)
Section titled “Mutable calls (s.call / s.tryCall)”s.call is s.read with the CALL opcode instead of STATICCALL. Same arg-encode, same
returndata-decode, same verbatim-revert-bubble, same try*-zeroing — but it runs in a real,
non-static frame, so it accepts nonpayable/payable functions that cannot run under
STATICCALL. There is no rollback machinery: a write performed via s.call is visible to a
later s.read in the same script (it is the same eth_call frame). The eth_call result itself
is still never committed on-chain.
A script also receives ETH: a bare call into it (empty calldata, any value) succeeds with no
output for 15 gas, inside the 2,300-gas stipend of Solidity’s transfer/send. So a target that pays
its caller — WETH.withdraw, a swap out to native ETH — works under s.call and s.simulate in
every execution mode, sender mode included. Calldata of 1–3 bytes
still reverts EvsInvalidCalldata().
The flagship case is a Uniswap quoter. Quoter functions are nonpayable (not view), so they
cannot run under STATICCALL — which is exactly why s.call exists. A QuoterV2-style function
returns its quote normally:
import { evscript, t } from '@maxencerb/evs';
// Build the struct arg with `s.tuple` (runtime Exprs flow in by name) — see the struct section above.const QuoteParams = { type: 'tuple', components: [ { name: 'tokenIn', type: 'address' }, { name: 'tokenOut', type: 'address' }, { name: 'amountIn', type: 'uint256' }, { name: 'fee', type: 'uint24' }, { name: 'sqrtPriceLimitX96', type: 'uint160' }, ],} as const;
const quoterV2Abi = [ { type: 'function', name: 'quoteExactInputSingle', stateMutability: 'nonpayable', // not view → s.read would be a compile error here inputs: [{ name: 'params', ...QuoteParams }], outputs: [ { name: 'amountOut', type: 'uint256' }, { name: 'sqrtPriceX96After', type: 'uint160' }, { name: 'initializedTicksCrossed', type: 'uint32' }, { name: 'gasEstimate', type: 'uint256' }, ], },] as const;
const quoteV2 = evscript( { name: 'quoteV2', args: [t.address, t.address, t.address, t.uint256, t.uint24] }, (s, quoter, tokenIn, tokenOut, amountIn, fee) => { const params = s.tuple(QuoteParams, { tokenIn, tokenOut, amountIn, fee, sqrtPriceLimitX96: 0n }); const out = s.call({ address: quoter, abi: quoterV2Abi, functionName: 'quoteExactInputSingle', args: [params], }); // ^? readonly [Expr<'uint256'>, Expr<'uint160'>, Expr<'uint32'>, Expr<'uint256'>] return s.return({ amountOut: out[0], gasEstimate: out[3] }); },);Decoding revert data as the result (revertReturns)
Section titled “Decoding revert data as the result (revertReturns)”A QuoterV1-style function instead reverts with the ABI-encoded result — its ABI declares no
outputs at all. Pass revertReturns, the list of output types carried by the revert payload, and
the call decodes that payload as its result. The list replaces the ABI outputs as the decode
schema (any outputs the ABI does declare are ignored), and the two branches swap: the revert is
the value path, and a normal return is the failure.
import { evscript, t } from '@maxencerb/evs';
const quoterV1Abi = [ { type: 'function', name: 'quoteExactInputSingle', stateMutability: 'nonpayable', inputs: [ { name: 'tokenIn', type: 'address' }, { name: 'tokenOut', type: 'address' }, { name: 'fee', type: 'uint24' }, { name: 'amountIn', type: 'uint256' }, { name: 'sqrtPriceLimitX96', type: 'uint160' }, ], outputs: [], // QuoterV1 reverts with the quote — nothing is returned normally },] as const;
const quoteV1 = evscript( { name: 'quoteV1', args: [t.address, t.address, t.address, t.uint24, t.uint256] }, (s, quoter, tokenIn, tokenOut, fee, amountIn) => { const amountOut = s.call({ address: quoter, abi: quoterV1Abi, functionName: 'quoteExactInputSingle', args: [tokenIn, tokenOut, fee, amountIn, 0n], revertReturns: [t.uint256], // the quote arrives in the revert data }); // ^? Expr<'uint256'> — typed from `revertReturns`, not from the ABI outputs return s.return({ amountOut }); },);The result is shaped like any other call result, but from the revertReturns list: [] →
void, [t.uint256] → Expr<'uint256'>, [t.uint256, t.string] → a positional tuple of
handles, and a t.struct(...) entry → a Tuple handle. The revert
payload goes through exactly the same decode sequence as returndata — the rds ≥ 32·n head guard
and the 2^64 bounds on dynamic outputs — so a malformed payload is a decode failure, never a
misread.
s.tryCall takes the same option. success is true when the target reverted with a payload
that decodes under revertReturns, and false — with value zeroed as usual — when it returned
normally or the revert payload is structurally malformed:
import { evscript, t } from '@maxencerb/evs';
const quoterV1Abi = [ { type: 'function', name: 'quoteExactInput', stateMutability: 'nonpayable', inputs: [{ name: 'amountIn', type: 'uint256' }], outputs: [], },] as const;
const tryQuoteV1 = evscript({ name: 'tryQuoteV1', args: [t.address, t.uint256] }, (s, quoter, amountIn) => { const r = s.tryCall({ address: quoter, abi: quoterV1Abi, functionName: 'quoteExactInput', args: [amountIn], revertReturns: [t.uint256], }); // r.success: the target reverted with a decodable quote; r.value: Expr<'uint256'> return s.return({ ok: r.success, amountOut: r.value });});Simulating writes (s.simulate / s.trySimulate)
Section titled “Simulating writes (s.simulate / s.trySimulate)”s.simulate dry-runs a true write and reads back what it would return, then throws the write
away. Use it to answer “what would this state-changing function return / mint / compute?” without
letting the change leak into the rest of your script.
import { evscript, t } from '@maxencerb/evs';
const vaultAbi = [ { type: 'function', name: 'deposit', stateMutability: 'nonpayable', inputs: [ { name: 'assets', type: 'uint256' }, { name: 'receiver', type: 'address' }, ], outputs: [{ name: 'shares', type: 'uint256' }], },] as const;
const previewDeposit = evscript( { name: 'previewDeposit', args: [t.address, t.uint256, t.address] }, (s, vault, assets, receiver) => { // Dry-run deposit(): read the shares it would mint, then roll the write back so a later // read of the vault sees the un-mutated state. const shares = s.simulate({ address: vault, abi: vaultAbi, functionName: 'deposit', args: [assets, receiver], }); // ^? Expr<'uint256'> return s.return({ shares }); },);How the rollback works
Section titled “How the rollback works”s.simulate lowers to a self-call plus a revert trampoline. The compiler builds the target
calldata like a normal call, wraps it as [trampolineSelector][target address][target calldata],
and issues a CALL to ADDRESS() — the script’s own address, where the script’s code is present
in both toViem() modes. A reserved second dispatcher entrypoint (the trampoline) decodes the
target and payload, performs the real CALL to the write target, and then REVERTs with a
magic-tagged payload [MAGIC][innerSuccess][target returndata].
That REVERT unwinds the sub-frame, discarding every state change the write made — that is the
rollback. The outer frame recognizes the magic word, branches on innerSuccess (strict: bubble the
target’s revert verbatim; try: success = false, zeroed value), and decodes the carried
returndata through the same in-memory tuple decoder every other verb uses.
s.trySimulate is the { success, value } companion — use it when the simulated write might revert
and you want a flag instead of bubbling. The trampoline self-call to ADDRESS() works in both
execution modes.
Nesting and composition
Section titled “Nesting and composition”The trampoline is an ordinary dispatcher entrypoint that runs in its own call frame, so simulate
sites compose without restriction: put one inside an s.fn body (called as
often as you like, from the script or from another s.fn), feed the result of one dry-run into the
next, or interleave them with s.call and s.read. Every hop rolls back independently — a dry-run
of a dry-run’s result still leaves the target’s state untouched.
import { evscript, t } from '@maxencerb/evs';
const vaultAbi = [ { type: 'function', name: 'deposit', stateMutability: 'nonpayable', inputs: [{ name: 'assets', type: 'uint256' }], outputs: [{ name: 'shares', type: 'uint256' }], }, { type: 'function', name: 'totalShares', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'uint256' }], },] as const;
const previewTwice = evscript( { name: 'previewTwice', args: [t.address, t.uint256] }, (s, vault, assets) => { // a dry-run inside a reusable fn (fn bodies take what they need as params) const preview = s.fn('preview', [t.address, t.uint256], (v, a) => s.simulate({ address: v, abi: vaultAbi, functionName: 'deposit', args: [a] }), ); const first = preview(vault, assets); const second = preview(vault, first); // the first dry-run's result feeds the second // both writes were rolled back: totalShares is what it was before the script ran const total = s.read({ address: vault, abi: vaultAbi, functionName: 'totalShares' }); return s.return({ first, second, total }); },);Capping a target’s gas
Section titled “Capping a target’s gas”Every verb accepts an optional gas (a literal or an Expr<'uint256'>) that caps what the target
may spend; without it the sub-call forwards all available gas. Under s.simulate / s.trySimulate
the cap bounds the inner target call: the self-call hop itself always forwards all gas, so the
trampoline can always report the outcome — a target that exhausts its cap surfaces as a failed
dry-run (empty revert under s.simulate, success = false under s.trySimulate), never as a lost
result. This is how you contain a misbehaving target without failing the whole eth_call:
import { evscript, t } from '@maxencerb/evs';
const vaultAbi = [ { type: 'function', name: 'deposit', stateMutability: 'nonpayable', inputs: [{ name: 'assets', type: 'uint256' }], outputs: [{ name: 'shares', type: 'uint256' }], },] as const;
const boundedPreview = evscript( { name: 'boundedPreview', args: [t.address, t.uint256] }, (s, vault, assets) => { const r = s.trySimulate({ address: vault, abi: vaultAbi, functionName: 'deposit', args: [assets], gas: 300_000n, // the dry-run may burn at most this much; the script goes on either way }); return s.return({ ok: r.success, shares: r.value }); },);Two EVM rules shape what gas means:
- The 63/64 rule (EIP-150). A
CALLcan hand its callee at most 63/64 of the caller’s remaining gas, whatever the cap says — sogasis an upper bound, never a guarantee. Size theeth_call’s own gas (the node’s cap, typically 30–50M) with your caps in mind. - Out-of-gas is an empty revert. A target that exhausts its cap fails with no revert data.
s.read/s.call/s.simulatebubble that empty revert; thetry*variants reportsuccess = falsewith a zeroedvalue.
Sending ETH (value)
Section titled “Sending ETH (value)”s.call, s.simulate and their try* forms take an optional value — a literal or an
Expr<'uint256'>, in wei — that the target receives as msg.value. It is accepted for a
payable function only: a nonpayable target reverts on any ETH, and s.read’s STATICCALL
cannot carry any, so both are compile errors (and an EvsTypeError at recording when the types are
bypassed). For an overloaded name, the overload the args resolve to decides.
The ETH comes from the script’s own balance: the script makes the call, not the eth_call’s
account. Run it in state-override mode and give the
script address a balance in the same override entry:
import { compile, evscript, t } from '@maxencerb/evs';import { createPublicClient, http, parseEther, zeroAddress } from 'viem';
const lidoAbi = [ { type: 'function', name: 'submit', stateMutability: 'payable', inputs: [{ name: '_referral', type: 'address' }], outputs: [{ name: '', type: 'uint256' }], },] as const;
const previewStake = evscript( { name: 'previewStake', args: [t.address, t.uint256] }, (s, lido, amount) => { // stake `amount` ETH, read the stETH shares it mints, then roll the deposit back const shares = s.simulate({ address: lido, abi: lidoAbi, functionName: 'submit', args: [zeroAddress], value: amount, }); return s.return({ shares }); },);
const compiled = compile(previewStake);const client = createPublicClient({ transport: http('https://eth.merkle.io') });const params = compiled.toViem({ mode: 'stateOverride' });
const result = await client.readContract({ ...params, // fund the script: same address and code, plus a balance stateOverride: [{ ...params.stateOverride[0], balance: parseEther('32') }], functionName: 'previewStake', args: ['0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', parseEther('32')], // Lido stETH});// result: { shares: bigint }- Sender mode runs the script at the sender’s address, so the sender’s own ETH pays; override
its
balancethe same way to dry-run more than it holds. - Deployless mode cannot fund the script: it runs at a counterfactual address created during the call, which holds no ETH. Use state-override mode for scripts that send value.
- An unfunded script fails the
CALLbefore the target runs:s.callreverts with empty data,s.simulaterevertsEvsDecodeError(site)(its self-call hop, which carries the value, is what fails), so does ans.callwithrevertReturns(the empty revert data cannot decode), and thetry*forms reportsuccess = falsewith a zeroedvalue.explainRevertnames the unpaidvalueas a candidate cause for each of these strict reverts, andcompilewarnsENV_FRAME_DEPENDENTfor every site that sends one. - Under
s.simulatethe ETH is rolled back with the rest of the write; unders.callit stays with the target for the rest of theeth_call(nothing is ever committed on-chain).
Choosing msg.sender: sender mode
Section titled “Choosing msg.sender: sender mode”There is no EVM primitive to change msg.sender inside a frame: whatever verb you use, the target
sees the address the script runs at. To give msg.sender-sensitive targets (permit-style checks,
onlyOwner views, ERC-4626 maxWithdraw(owner) patterns, balanceOf(msg.sender) inside a
write) a chosen sender, run the script at that address with
toViem({ mode: 'stateOverride', sender }): the runtime is
installed at sender through stateOverride and the script self-calls through it, so every
sub-call — including the simulated write — sees msg.sender = sender, and s.env('caller') /
s.env('address') read sender too.
The complete example — previewing an ERC-4626 withdraw that burns the owner’s shares — is in
executing scripts: sender mode.
Native balances and legacy metadata
Section titled “Native balances and legacy metadata”Two reads need less than they seem to: an ETH balance needs no contract call at all, and legacy
bytes32 metadata needs no client-side decoding step.
Native ETH balances
Section titled “Native ETH balances”An account’s ETH balance is not a contract call: s.balance(address)
reads it with the BALANCE opcode, on any chain and with no target contract. In a batch loop:
import { evscript, t } from '@maxencerb/evs';
const nativeBalances = evscript({ name: 'nativeBalances', args: [t.array(t.address)] }, (s, wallets) => { const balances = s.newArray(t.uint256, wallets.length()); s.forEach(wallets, (wallet, i) => { balances.set(i, s.balance(wallet)); }); return s.return({ balances });});Legacy bytes32 metadata
Section titled “Legacy bytes32 metadata”A few early tokens (MKR, SAI) return bytes32 from name() and symbol() instead of string.
Reading a real string through the string ABI succeeds; reading a bytes32 through it fails to
decode (the word is not a valid offset), so s.tryRead’s success tells the two apart. When it
is false, read the same function through a bytes32 ABI and convert the word in-script with
asString(), which drops its trailing zero padding:
import { evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';
const bytes32MetadataAbi = [ { type: 'function', name: 'symbol', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'bytes32' }], },] as const;
const tokenSymbol = evscript({ name: 'tokenSymbol', args: [t.address] }, (s, token) => { const asString = s.tryRead({ address: token, abi: erc20Abi, functionName: 'symbol' }); const symbol = s.let(t.string, asString.value); s.if(asString.success.not(), () => { const raw = s.tryRead({ address: token, abi: bytes32MetadataAbi, functionName: 'symbol' }); symbol.set(raw.value.asString()); // 'MKR' + 29 zero bytes → 'MKR' }); return s.return({ symbol: symbol.get() });});The bytes32 read only runs for the tokens whose string read failed; see
the legacy-symbol pattern.
When to use which
Section titled “When to use which”Pick the verb by the callee’s mutability first, then by what you need from the result:
s.read/s.tryRead—view/purereads. Reach fors.readwhen the read is expected to succeed and a failure should abort the whole script (the verbatim bubble means nothing is lost in translation); reach fors.tryReadwhen:- the data is optional (
decimals()on nonstandard tokens, with a default vias.select); - you are probing addresses that may not implement the function — or may not be contracts at
all. A
STATICCALLto an address without code “succeeds” with empty returndata. If the function declares outputs,s.readreverts asEvsDecodeErrorands.tryReadreportssuccess = false. For a function with no outputs there is nothing to decode, sos.readands.callsucceed and thetry*verbs reportsuccess = true— that proves nothing about whether the target is a contract (unlike Solidity ≥ 0.8.10, evs adds noextcodesizecheck — guard withs.codeSize(target).gt(0n)when it matters); - one bad element must not kill a batch loop — see batch token balances.
- the data is optional (
s.call/s.tryCall— anonpayable/payablefunction you need a real frame for but whose state effect you don’t care to discard (a Uniswap quoter, a function that touches transient state a laters.readshould see). The write persists within theeth_call; nothing reaches the chain. AddrevertReturnswhen the target reports its result by reverting (QuoterV1).s.simulate/s.trySimulate— anonpayable/payablewrite whose return value you want but whose state change must not leak into later reads in the same script (an ERC-4626deposit/mintpreview, anypreviewXthat doesn’t exist as aview).
For more composed examples, see the Uniswap V3 flagship and patterns. The frozen signatures live in the ScriptBuilder reference.