Skip to content

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

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.

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.

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 const degrades to functionName: string, args: readonly unknown[], and Expr/Tuple outputs instead of erroring.
  • Overloaded names throw EvsTypeError at recording; disambiguate by pruning the ABI to the overload you want.
  • Restricted shapes — two-level tuple arrays (tuple[][]), arrays nested deeper than [][], and fixed T[N] in an argument or output throw EvsTypeError at 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.

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 });
},
);

When a callee reverts under s.read (and likewise s.call/s.simulate), the entire script reverts with the callee’s revert data, byte-exactError(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, and explainRevert maps 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 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) });
});
  • success is Expr<'bool'> — false when the call failed or when the returndata is structurally malformed. Both cases fold into the same flag.
  • value has the same shape s.read would produce (single output unwrapped, several as a tuple). On failure every output is a safe default: zero for numeric and word types, false for bool, the zero address, empty string/bytes, empty arrays. value is always safe to use — guard with success (or s.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.

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 });
},
);

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.

Pick the verb by the callee’s mutability first, then by what you need from the result:

  • s.read / s.tryReadview/pure reads. Reach for s.read when the read is expected to succeed and a failure should abort the whole script (the verbatim bubble means nothing is lost in translation); reach for s.tryRead when:
    • the data is optional (decimals() on nonstandard tokens, with a default via s.select);
    • you are probing addresses that may not implement the function — or may not be contracts at all. A STATICCALL to an address without code “succeeds” with empty returndata, which s.read reverts as EvsDecodeError but s.tryRead reports as success = false;
    • one bad element must not kill a batch loop — see batch token balances.
  • s.call / s.tryCall — a nonpayable/payable function 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 later s.read should see). The write persists within the eth_call; nothing reaches the chain.
  • s.simulate / s.trySimulate — a nonpayable/payable write whose return value you want but whose state change must not leak into later reads in the same script (an ERC-4626 deposit/mint preview, any previewX that doesn’t exist as a view).

For more composed examples, see the Uniswap V3 flagship and patterns. The frozen signatures live in the ScriptBuilder reference.