Skip to content

ScriptBuilder

ScriptBuilder is the s handed to your evscript callback — you never construct one. Every member records statements into the script’s IR at recording time and validates eagerly: type mismatches, out-of-range literals, scope violations, and staging misuse all throw an EvsError subclass at the offending line (see diagnostics). After s.return the recorder seals; any later builder call throws EvsScopeError (RECORDING_CLOSED).

Member signatures below are interface excerpts, not runnable modules. Expr, IntoExpr, LitOf, and the type vocabulary are documented in the types reference.

Script arguments — positional callback params

Section titled “Script arguments — positional callback params”

Script args are declared on the header as t.* types (a lone type or a readonly list) and arrive as positional parameters after s in the body callback — there is no s.args. A scalar arg arrives as an Expr; a TupleType arg as a Tuple handle. Property access records nothing — argument values are decoded and validated once at script entry. See the evscript reference for the full signature.

import { evscript, t } from '@maxencerb/evs';
const sum = evscript(
{ name: 'sum', args: [t.uint256, t.uint256] },
(s, a, b) => s.return({ total: a.add(b) }),
);
lit<const t extends EvsType>(type: t, value: LitOf<t>): Expr<t>;

The explicit literal constructor, for when coercion via IntoExpr cannot infer the type you want. Literals are validated at recording time against the coercion rules. Word literals canonicalize into the bytecode as constants; dynamic literals (string, bytes, literal arrays) become bytecode data segments materialized by CODECOPY on first use.

import { evscript, t } from '@maxencerb/evs';
const consts = evscript({ name: 'consts', args: [] }, (s) => {
const fees = s.lit(t.array(t.uint24), [100n, 500n, 3000n, 10000n]); // data segment
const label = s.lit(t.string, 'hello'); // data segment
const max = s.lit(t.uint128, 2n ** 128n - 1n); // canonical word
return s.return({ first: fees.at(0n), labelLen: label.length(), max });
});
let<const t extends EvsType>(type: t, init: IntoExpr<t>): Cell<t>;
let<t extends EvsType>(init: Expr<t>): Cell<t>;

Declares a mutable cell — the only mutable binding in a script, and the way values escape s.if branches and loop bodies. The one-argument overload requires an Expr (the cell type is inferred); to seed a cell with a literal, use the two-argument overload. A Cell is not an Expr: reads are always an explicit .get(), so “snapshot vs current value” is visible at every use.

import { evscript, t } from '@maxencerb/evs';
const clamp = evscript({ name: 'clamp', args: [t.uint256] }, (s, x) => {
const acc = s.let(t.uint256, 0n); // typed literal init
const copy = s.let(x); // type inferred: Cell<'uint256'>
s.if(
x.gt(100n),
() => acc.set(100n),
() => acc.set(x),
);
return s.return({ clamped: acc.get(), original: copy.get() });
});
newArray<const e extends EvsType>(elem: e, length: IntoExpr<'uint256'>): MutArray<e>;

Allocates a zero-filled mutable array with a runtime (or literal) length. elem may be a word type, string/bytes, a one-level array (uint256[]), or a struct/tuple (tuple) — the resulting array is then a tuple[]; a tuple[] element is rejected (UNSUPPORTED_V0). This is the building block for “loop over inputs, collect outputs” — the multicall replacement pattern (see token balances).

  • Runtime lengths of 2^32 or more revert with Panic 0x41 (allocation too large).
  • A literal length of 2^32 or more throws at recording time (CERTAIN_PANIC).
  • Allocating inside a loop allocates fresh memory every iteration — compile warns with LOOP_ALLOCATION.
  • A composite element (tuple, string/bytes, T[]) makes the array an array of pointers; get(i) returns the element handle (a Tuple for a tuple element), a reference into slot i, and set(i, v) takes the element’s IntoMember (a Tuple/literal for a tuple element).
import { evscript, t } from '@maxencerb/evs';
const squares = evscript({ name: 'squares', args: [t.uint256] }, (s, n) => {
const out = s.newArray(t.uint256, n); // zero-filled uint256[n]
s.for({ type: t.uint256, from: 0n, until: n }, (i) => {
out.set(i, i.mul(i));
});
return s.return({ squares: out.expr() });
});

A tuple[] array — get(i) returns the element Tuple, filled in place:

import { evscript, t } from '@maxencerb/evs';
const Position = t.struct({ liquidity: t.uint128, owner: t.address });
const positions = evscript({ name: 'positions', args: [t.address, t.uint256] }, (s, owner, n) => {
const out = s.newArray(Position, n); // zero-filled tuple[n] (array of pointers)
s.for({ type: t.uint256, from: 0n, until: n }, (i) => {
const el = out.get(i); // Tuple handle into slot i
el.liquidity.set(i.toUint(t.uint128));
el.owner.set(owner);
});
return s.return({ positions: out.expr() }); // readonly { liquidity; owner }[]
});
tuple<const c extends TupleType>(type: c, init?: TupleInit<c>): Tuple<c>;

Allocates a tuple/struct in memory and returns a Tuple handle. type is a TupleType — a t.struct/t.tuple (or a raw readonly AbiParameter[]). init is a partial, name-keyed (struct) or positional (t.tuple) record of members; every member is optional, defaults to zero, and accepts a literal, an Expr, or a nested Tuple. Use the returned handle’s Field.set to fill or overwrite members later.

import { evscript, t } from '@maxencerb/evs';
const Position = t.struct({ liquidity: t.uint128, owner: t.address });
const mkPos = evscript({ name: 'mkPos', args: [t.address] }, (s, owner) => {
const pos = s.tuple(Position, { liquidity: 42n, owner }); // alloc + zero-fill + set members
pos.liquidity.set(pos.liquidity.get().add(1n)); // read-modify-write a field
return s.return({ liq: pos.liquidity.get(), pos }); // a field, and the whole struct (returned directly)
});

The allocation is zero-filled, so omitted members start at zero with no extra write. A tuple handle is a pointer with reference semantics: copying it (or passing it to a call) shares the same block, so a later .set() is visible through every alias. See composite types in values & types.

env(kind: 'address' | 'caller' | 'timestamp' | 'blocknumber' | 'chainid'):
Expr<'address'> /* for 'address' | 'caller' */ | Expr<'uint256'> /* for the rest */;

Reads execution-environment values. 'address' and 'caller' produce Expr<'address'>; the other kinds produce Expr<'uint256'>. An unknown kind throws EvsTypeError.

import { evscript } from '@maxencerb/evs';
const ctx = evscript({ name: 'ctx' }, (s) =>
s.return({
me: s.env('caller'), // frame-dependent
here: s.env('address'), // frame-dependent
ts: s.env('timestamp'),
block: s.env('blocknumber'),
chain: s.env('chainid'),
}),
);
add<t extends NumericType>(a: IntoExpr<t>, b: IntoExpr<t>): Expr<t>; // likewise sub/mul/div/mod

Free-function mirrors of the checked arithmetic Expr methods, for literal-left operands (s.sub(10_000n, x) — a method cannot put the literal on the left). Same semantics: overflow or underflow reverts Panic 0x11; division or modulo by zero reverts Panic 0x12; signed minN / -1 reverts Panic 0x11. See arithmetic.

At least one operand must be an Expr — an all-literal call throws EvsTypeError (“type a literal with s.lit”). When one operand is a literal-valued Expr, the operation folds at recording, and a fold that would certainly panic throws CERTAIN_PANIC instead of compiling a guaranteed revert.

import { evscript, t } from '@maxencerb/evs';
const remaining = evscript({ name: 'remaining', args: [t.uint256] }, (s, used) =>
s.return({ left: s.sub(10_000n, used) }),
);

s.lt / s.gt / s.lte / s.gte / s.eq / s.neq

Section titled “s.lt / s.gt / s.lte / s.gte / s.eq / s.neq”
lt<t extends NumericType>(a: IntoExpr<t>, b: IntoExpr<t>): Expr<'bool'>; // likewise gt/lte/gte
eq<t extends WordType>(a: IntoExpr<t>, b: IntoExpr<t>): Expr<'bool'>; // likewise neq

Comparison mirrors. Ordering comparisons are numeric-only; signed vs unsigned EVM opcodes (LT/GT vs SLT/SGT) are chosen from the static type. eq/neq accept any word type (address, bool, bytesN included) but not string/bytes/arrays — there is no deep equality in v0. Operand types must match exactly; the error message suggests toUint/toInt when they do not.

and(a: IntoExpr<'bool'>, b: IntoExpr<'bool'>): Expr<'bool'>; // likewise or
not(a: IntoExpr<'bool'>): Expr<'bool'>;

Boolean logic on Expr<'bool'> values. Eager, not short-circuiting — both operands are already-computed values by the time and/or records. For conditional execution use s.if.

s.bitAnd / s.bitOr / s.bitXor / s.bitNot / s.shl / s.shr

Section titled “s.bitAnd / s.bitOr / s.bitXor / s.bitNot / s.shl / s.shr”
bitAnd<t extends BitsType>(a: IntoExpr<t>, b: IntoExpr<t>): Expr<t>; // likewise bitOr/bitXor
bitNot<t extends BitsType>(a: Expr<t>): Expr<t>;
shl<t extends BitsType>(a: Expr<t>, bits: IntoExpr<'uint256'>): Expr<t>; // likewise shr

Bitwise mirrors over BitsType (uintN or bytesN). Results are re-canonicalized to the operand’s width; shifts never panic — bits shifted out are dropped. Details and the per-type lane semantics: arithmetic.

encode(...values: [EncodeValue, ...EncodeValue[]]): Expr<'bytes'>; // abi.encode
encodePacked(...values: [PackedValue, ...PackedValue[]]): Expr<'bytes'>; // abi.encodePacked
keccak256(...values: [EncodeValue, ...EncodeValue[]]): Expr<'bytes32'>; // keccak256(abi.encode(...))

Byte-exact mirrors of Solidity’s encoding and hashing primitives. EncodeValue is any staged handle (Expr | Tuple | MutArray); PackedValue excludes Tuple (packed mode carries Solidity’s restrictions — words, string/bytes, and word-element arrays only). At least one value is required, and literals must be lifted with s.lit. s.keccak256 hashes the standard encoding — keccak256(abi.encode(...)), structs included — except that a lone bytes/string value is hashed directly (Solidity’s keccak256(b)); the non-standard packed hash is always the explicit s.keccak256(s.encodePacked(...)) composition. Semantics and the packed rules: hashing & ABI encoding.

if(cond: IntoExpr<'bool'>, then: () => void, otherwise?: () => void): void;

Runtime branch combinator. cond is a plain value, evaluated once before the branch. The then/otherwise callbacks run immediately at recording time to capture each branch’s statements; on-chain, only the taken branch executes. Values recorded inside a branch are scoped to it — use a cell to get a value out.

while(cond: () => IntoExpr<'bool'>, body: (loop: LoopCtl) => void): void;

Runtime loop. The condition is a thunk: the ops it records land in the loop header and re-execute every iteration. Values recorded in the header are visible in the body; nothing recorded inside the loop (header or body) is visible after it — carry state in cells.

import { evscript, t } from '@maxencerb/evs';
const log2 = evscript({ name: 'log2', args: [t.uint256] }, (s, x) => {
const v = s.let(t.uint256, x);
const bits = s.let(t.uint256, 0n);
s.while(
() => v.get().gt(1n), // recorded once, executes every iteration
() => {
v.set(v.get().div(2n));
bits.set(bits.get().add(1n));
},
);
return s.return({ bits: bits.get() });
});
for<const t extends NumericType>(
range: { type: t; from: IntoExpr<t>; until: IntoExpr<t>; step?: IntoExpr<t> },
body: (i: Expr<t>, loop: LoopCtl) => void,
): void;

Counted-loop sugar over s.while plus an internal cell, generic over any numeric word type. Iterates while i < until; step defaults to 1. until and step are snapshot once, before the loop. The step increment uses checked arithmetic — if the counter would overflow its type before reaching until, the script panics 0x11. loop.continue() executes the step first, then jumps to the header.

See the s.newArray example above for the canonical collect-into-array loop.

select<t extends EvsType>(cond: IntoExpr<'bool'>, a: IntoExpr<t>, b: IntoExpr<t>): Expr<t>;

Value-level conditional: returns a when cond is true, else b. Both sides are eager — they are already-computed values, so this never skips work (use s.if plus a cell for conditional execution). Branch types must match exactly, and at least one branch must be an Expr. The classic use is a default for a failed s.tryRead.

Sub-calls split by mutability + call frame (issue #1). All six verbs share the same parameter object and the same struct-aware overloads (default positional / struct: true / non-literal boolean, plus a try* variant returning { success, value }); they differ only in the opcode they emit and the functionName mutability bucket they accept:

Verb Opcode Mutability bucket State
read / tryRead STATICCALL view / pure static — no write possible
call / tryCall CALL (value 0) nonpayable / payable real frame; the write is not rolled back — it persists to later sub-calls in the same eth_call
simulate / trySimulate CALL via self-call trampoline nonpayable / payable the write is rolled back, yet its return value is read back

The mutability bucket is enforced at the functionName type level: a nonpayable function under read is a compile error (steered to call/simulate); a view/pure function under call/simulate is a compile error (steered to read). The full semantics and worked examples live in calling contracts.

read<const abi extends Abi | readonly unknown[], name extends ContractFunctionName<abi, 'pure' | 'view'>>(p: {
readonly address: IntoExpr<'address'>;
readonly abi: abi;
readonly functionName: name; // autocomplete union over view/pure functions
readonly args?: SubcallInputs; // per parameter: ABI primitive/Expr; a tuple param: Tuple/s.tuple/literal object
readonly gas?: IntoExpr<'uint256'>; // optional cap; default forward-all
readonly struct?: boolean; // opt-in: fuse multiple named outputs into ONE named Tuple (ABI order)
}): CallOutputs; // [] → void; [one] → Expr<one> or Tuple<one>; [many] → readonly tuple of Expr|Tuple (or one Tuple with struct:true)

A STATICCALL to another contract, typed like viem’s readContract (SubcallInputs and CallOutputs stand in for package-internal helper types). The address can itself be an Expr — values flow between calls on-chain, which is the whole point. Key semantics (exhaustive treatment in calls):

  • Only view/pure functions are offered; nonpayable/payable names are TypeScript errors — reach for s.call or s.simulate for those.
  • A 'tuple' output decodes into a Tuple handle (named/positional field access); a 'tuple' argument accepts a Tuple handle, a s.tuple(...) result, or a plain literal object. See calls — struct arguments and outputs.
  • By default a function’s multiple outputs decode to the positional [many] shape (a readonly tuple of Expr | Tuple, mirroring viem). Passing struct: true instead fuses them into one named Tuple handle in ABI declaration order (e.g. s.read({ …, functionName: 'slot0', struct: true }).sqrtPriceX96.get()), so it unifies with a t.struct / t.fromOutputs(abi, name) in the same order. Every output must be named, else EvsTypeError at recording; a non-literal boolean widens the result to the union of both shapes. s.tryRead takes struct too. See calls — decoding multiple outputs into a struct.
  • Callee reverts bubble verbatim (Error/Panic/custom alike).
  • Structurally malformed returndata reverts EvsDecodeError(site); explainRevert maps the site back to your source line.
  • Dirty high bits in word outputs are normalized, not reverted.
  • A non-as const ABI degrades gracefully: functionName: string, untyped args, outputs as readonly (Expr | Tuple)[] — never a hard type error.
  • Overloaded function names and ABI parameter types outside the current scope throw EvsTypeError at recording.
import { evscript, t } from '@maxencerb/evs';
import { erc20Abi } from 'viem';
const tokenInfo = evscript(
{ name: 'tokenInfo', args: [t.address, t.address] },
(s, token, owner) => {
const symbol = s.read({ address: token, abi: erc20Abi, functionName: 'symbol' });
// symbol: Expr<'string'>
const bal = s.read({
address: token,
abi: erc20Abi,
functionName: 'balanceOf',
args: [owner], // literals and Exprs mix freely
gas: 100_000n, // optional per-call gas cap
});
return s.return({ symbol, bal });
},
);
tryRead<const abi extends Abi | readonly unknown[], name extends ContractFunctionName<abi, 'pure' | 'view'>>(
p: SubcallParams, // identical to s.read
): { readonly success: Expr<'bool'>; readonly value: CallOutputs };

Like s.read, but failure becomes data instead of a revert. success is false when the call fails or when the returndata is structurally malformed; value is then zeros, empty strings, empty arrays, or zero-filled tuples — always safe to use. (This is stricter than Solidity’s try/catch, which would surface malformed returndata differently.)

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' });
return s.return({ decimals: s.select(d.success, d.value, 18) }); // default on failure
});
call<const abi extends Abi | readonly unknown[], name extends ContractFunctionName<abi, 'nonpayable' | 'payable'>>(
p: SubcallParams, // identical to s.read, but functionName is filtered to nonpayable/payable
): CallOutputs;

Identical encode/decode/verbatim-bubble path to s.read, but emits a CALL (value 0) instead of STATICCALL — “read with the CALL opcode”. Use it for nonpayable/payable functions that can’t run under STATICCALL but don’t usefully persist state: the canonical case is a Uniswap quoter. There is no rollback — a write done via s.call is visible to a later s.read in the same script (the same eth_call frame), though the eth_call result is still never committed on-chain. The target sees msg.sender = the script’s address (frame-dependent — see calls). s.tryCall is the { success, value } variant.

import { evscript, t } from '@maxencerb/evs';
const quoterAbi = [
{
type: 'function',
name: 'quoteExactInputSingle',
stateMutability: 'nonpayable', // not view → needs s.call, not s.read
inputs: [
{ name: 'tokenIn', type: 'address' },
{ name: 'tokenOut', type: 'address' },
{ name: 'amountIn', type: 'uint256' },
{ name: 'fee', type: 'uint24' },
],
outputs: [{ name: 'amountOut', type: 'uint256' }],
},
] as const;
const quote = evscript(
{ name: 'quote', args: [t.address, t.address, t.address, t.uint256, t.uint24] },
(s, quoter, tokenIn, tokenOut, amountIn, fee) => {
const amountOut = s.call({
address: quoter,
abi: quoterAbi,
functionName: 'quoteExactInputSingle',
args: [tokenIn, tokenOut, amountIn, fee],
});
return s.return({ amountOut });
},
);
simulate<const abi extends Abi | readonly unknown[], name extends ContractFunctionName<abi, 'nonpayable' | 'payable'>>(
p: SubcallParams, // identical to s.call
): CallOutputs;

Dry-runs a true write and reads back what it would return, then rolls the write back so it is isolated from later reads in the same script. It lowers to a self-call to ADDRESS() (the script’s own code) through a reserved trampoline entrypoint: the trampoline performs the real CALL to the target and then REVERTs, which unwinds the sub-frame and discards every state change — that revert is the rollback. The carried returndata is recognized by a magic word and decoded normally. As with s.call, the target sees msg.sender = the script’s address. s.trySimulate is the { success, value } variant. Full mechanism: simulating writes.

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 previewDeposit = evscript(
{ name: 'previewDeposit', args: [t.address, t.uint256] },
(s, vault, assets) => {
// What WOULD deposit() mint? Read the return value, then roll the write back.
const shares = s.simulate({ address: vault, abi: vaultAbi, functionName: 'deposit', args: [assets] });
return s.return({ shares });
},
);
fn<
const params extends readonly ArgSpec[],
const r extends Expr | Tuple | MutArray | readonly (Expr | Tuple | MutArray)[] | void,
>(
name: string,
params: params,
body: (...args: { [i in keyof params]: Expr<params[i]['type']> }) => r,
): (...args: { [i in keyof params]: IntoExpr<params[i]['type']> }) => FreshHandles<r>;

Defines a reusable typed subroutine and returns a callable handle. A body may return a single Expr, a single Tuple (a struct/composite result), a single MutArray (an array result), a readonly list of those (the [many] shape), or void. (FreshHandles stands in for a package-internal helper: Expr results come back as fresh Exprs, Tuple results as fresh Tuple handles, array/MutArray results as fresh array Exprs at the call site, and void as void.) Rules (full guide):

  • The body runs once, at definition, in an isolated scope: params only, no capture of outer Exprs or cells (EvsScopeError at recording).
  • s.fn params accept a bare t.* type, a single namedArg(name, type), or a readonly list mixing named/bare (issue #9) — a namedArg labels the callback parameter, a bare param keeps the positional arg{i} name.
  • Each call of the returned handle records one statement and returns fresh handles — two calls never alias.
  • Compiled as a JUMPDEST subroutine: code is emitted once regardless of call count; uncalled fns are dropped. Recursion is unconstructible.
import { namedArg, evscript, t } from '@maxencerb/evs';
import { erc20Abi } from 'viem';
const pairBalances = evscript(
{ name: 'pairBalances', args: [t.address, t.address, t.address] },
(s, a, b, who) => {
const balOf = s.fn(
'balOf',
[namedArg('token', t.address), namedArg('owner', t.address)] as const,
(token, owner) =>
s.read({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [owner] }),
);
return s.return({
balA: balOf(a, who),
balB: balOf(b, who),
});
},
);
// each value is an Expr, a Tuple handle, OR a MutArray handle — directly:
return<const ret extends Record<string, Expr | Tuple<TupleType> | MutArray<EvsType>>>(values: ret): ScriptReturn<ret>;

Declares the script’s outputs and seals the recorder. Must be called exactly once, unconditionally, at the top level of the callback (not inside s.if/s.while/s.for bodies, not inside an s.fn body), and its result must be what the callback returns.

  • Record keys become the named components of the single tuple output; viem consumers receive an object. Keys must be identifiers; empty keys are rejected (ABI_SHAPE) because an unnamed component silently degrades viem’s result to a positional array.
  • Each value is an Expr, or a Tuple handle directly — a struct/tuple flows out as a 'tuple' component, abitype-typed. (tupleHandle.expr() is equivalent and still valid, for the bare memref Expr.) Type bare literals with s.lit first.
  • A MutArray handle is also returnable directly (no .expr()) — the array flows out as its abitype-typed array shape; arr.expr() is equivalent and still valid. The per-value type is ReturnValue = Expr | Tuple | MutArray.
  • After sealing, any builder call throws EvsScopeError (RECORDING_CLOSED).
import type { EvsType, Expr, IntoExpr } from '@maxencerb/evs';
interface Cell<t extends EvsType> {
readonly type: t;
get(): Expr<t>; // fresh snapshot at this program point
set(value: IntoExpr<t>): void;
}

Returned by s.let. get() records a read — the resulting Expr is a snapshot of the cell at that program point, not a live reference. A cell is only usable while its defining scope is on the recording stack; touching it elsewhere throws EvsScopeError.

interface MutArray<e extends EvsType> {
readonly elemType: e;
readonly length: Expr<'uint256'>;
set(i: IntoExpr<'uint256'>, v: IntoMember<e>): void; // bounds-checked → Panic 0x32
get(i: IntoExpr<'uint256'>): MutArrayElem<e>; // bounds-checked → Panic 0x32
expr(): Expr<MutArrayValueOf<e>>; // handle to the SAME buffer (reference semantics)
}

Returned by s.newArray. All indexed access is bounds-checked (Panic 0x32). expr() returns a plain Expr array handle aliasing the same buffer — later set() calls are visible through it. length is recorded once, at construction. For a word element the slots are inline values: set/get take/return an Expr<e> and expr() is the array Expr (the e[] handle). For a composite element (tuple/string/bytes/T[]) the array is an array of pointers: get(i) returns the element handle (a Tuple for a tuple element) — a reference into slot i — and set(i, v) takes the element’s IntoMember (a Tuple handle / literal for a tuple element).

type Tuple<C extends TupleType> = {
// one property per NAMED component → a Field over that member:
readonly [name in NamedComponents<C>]: Field<MemberType<name>>;
at(i: number): Field<>; // positional accessor (literal index)
expr(): Expr<C>; // the raw memref — for s.return or passing as a call arg
};

A tuple/struct memref handle. Produced by s.tuple, by a TupleType script arg, and by a 'tuple' output of s.read. For each named component it exposes a property keyed by the component name returning a Field; at(i) is the positional accessor (i is a recording-time literal number in [0, memberCount) — tuples are flat with a fixed member count, so there is no runtime member indexing; passing a non-literal throws TYPE_MISMATCH. Use an array’s .at(Expr) for runtime indexing); and expr() is the raw Expr<C> memref. The handle is itself returnable from s.return and passable as a 'tuple' call argument directlyexpr() is only needed when you want the bare Expr<C>. A tuple handle is a pointer with reference semantics (passing it shares the block).

import { evscript, t } from '@maxencerb/evs';
const slot0Abi = [
{
type: 'function',
name: 'slot0Struct',
stateMutability: 'view',
inputs: [],
outputs: [
{
name: '',
type: 'tuple',
components: [
{ name: 'sqrtPriceX96', type: 'uint160' },
{ name: 'tick', type: 'int24' },
{ name: 'unlocked', type: 'bool' },
],
},
],
},
] as const;
const readSlot0 = evscript({ name: 'readSlot0', args: [t.address] }, (s, pool) => {
const slot0 = s.read({ address: pool, abi: slot0Abi, functionName: 'slot0Struct' });
// ^? Tuple<…> — the 'tuple' output decoded into a handle
return s.return({
tick: slot0.tick.get(), // Expr<'int24'> by name
unlocked: slot0.at(2).get(), // Expr<'bool'> by position
slot0, // a Tuple handle is returnable directly — the whole struct flows out, abitype-typed
});
});
interface Field<t extends EvsType> {
get(): t extends TupleType ? Tuple<t> : Expr<t>;
set(value: IntoMember<t>): void; // IntoExpr<t> for scalars; a Tuple/literal for composite members
}

A handle over one tuple member (Cell-like). get() reads the member: a scalar member yields an Expr, a nested-tuple member yields a Tuple handle. set(v) writes it — tupleset under the hood, so writes are visible through every alias of the owning tuple. Obtained from a Tuple by name (pos.liquidity) or position (pos.at(0)).

interface LoopCtl {
break(): void; // jump past the owning loop
continue(): void; // jump to the owning loop's header (s.for: runs the step first)
}

Passed to s.while/s.for bodies. Scoped: calling either method outside the recording of its owning loop’s body throws EvsScopeError.

import type { ReturnValue } from '@maxencerb/evs';
declare const returnBrand: unique symbol;
interface ScriptReturn<ret extends Record<string, ReturnValue>> {
readonly [returnBrand]: ret;
}

ReturnValue is Expr | AnyTuple | AnyMutArray — an Expr, a Tuple handle, or a MutArray handle. The opaque branded token produced by s.return and required as the callback’s return value. It exists purely so the type system can thread the return record’s literal type into EvsScript — you never construct or inspect one.