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) |
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 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). 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.
| 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 |
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 sub-call; by default all available gas is forwarded |
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 — see below |
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 — an unnamed output throws EvsTypeError at recording (an unnamed
component would silently degrade viem’s result to a positional array). 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.
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 constdegrades tofunctionName: string,args: readonly unknown[], andExpr/Tupleoutputs instead of erroring. - Overloaded names throw
EvsTypeErrorat recording; disambiguate by pruning the ABI to the overload you want. - Restricted shapes — two-level tuple arrays (
tuple[][]), arrays nested deeper than[][], and fixedT[N]in an argument or output throwEvsTypeErrorat recording, naming the parameter. Plain structs/tuples, arrays of structs (tuple[]), one-level nested arrays (uint256[][]), and dynamic-leaf arrays (string[]/bytes[]) are all supported — see struct and array arguments and outputs.
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 a plain literal
object — build it with s.tuple and pass it in args:
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] }); return s.return({ amountOut }); },);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 a plain readonly Struct[] literal. 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 non-contract address returning nothing) — 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 source line of the offending call. See errors and debugging.
Dirty high bits in word-typed outputs are normalized, not reverted (viem-lenient): a
uint8 output with garbage in its upper bytes comes back masked to its declared width.
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.
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.
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] }); },);A QuoterV1-style function instead reverts with the ABI-encoded result — so call it with
s.tryCall, which reports success = false on that revert instead of bubbling it:
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: [{ name: 'amountOut', type: 'uint256' }], },] 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 r = s.tryCall({ address: quoter, abi: quoterV1Abi, functionName: 'quoteExactInputSingle', args: [tokenIn, tokenOut, fee, amountIn, 0n], }); // QuoterV1 reverts with the encoded quote, so r.success is false on the revert path. 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.
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, whichs.readreverts asEvsDecodeErrorbuts.tryReadreports assuccess = false; - 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.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.