Ethereum Virtual Script
eth_call, so reads that depend on earlier reads cost a single round trip. Nothing to deploy, and viem types the result.bun add @maxencerb/evs viem- One eth_call
- Dependent reads run inside a single RPC round trip.
- Typed through viem
- The script is its own ABI; readContract infers args and result.
- Nothing to deploy
- Deployless by default, state override when msg.sender matters.
- solc 0.8 semantics
- Checked arithmetic, verbatim revert bubbling, panic codes.
From ABI to typed result in three steps
Section titled “From ABI to typed result in three steps”The example reads a Uniswap V3 pool’s two tokens and each token’s symbol: four reads, two of
which target addresses that only exist once the first two have returned. One eth_call runs all
of them.
-
Describe the contracts. Plain
as constABI fragments, the same shape viem uses. viem already shipserc20Abi, so only the pool needs writing down.abis.ts export const poolAbi = [{ type: 'function', name: 'token0', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },{ type: 'function', name: 'token1', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },] as const; -
Write the script. The callback runs once, at build time, and records what the bytecode will do. Every value it touches is an
Expr, a typed placeholder for something that only exists inside theeth_call. Passing one call’s result as the next call’s address is the part a multicall cannot express.script.ts import { evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';5 collapsed lines// abis.ts (step 1)const poolAbi = [{ type: 'function', name: 'token0', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },{ type: 'function', name: 'token1', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },] as const;export const poolTokens = evscript({ name: 'poolTokens', args: [t.address] }, (s, pool) => {const token0 = s.read({ address: pool, abi: poolAbi, functionName: 'token0' });// ^? Expr<'address'>const token1 = s.read({ address: pool, abi: poolAbi, functionName: 'token1' });// The target of these two calls is the result of the previous two.const symbol0 = s.read({ address: token0, abi: erc20Abi, functionName: 'symbol' });const symbol1 = s.read({ address: token1, abi: erc20Abi, functionName: 'symbol' });return s.return({ token0, token1, symbol0, symbol1 });}); -
Compile it and run it with viem.
compileproduces verified runtime bytecode plus a literal-typed ABI.toViem()hands both toreadContractas a deploylesseth_call, and the argument list and the result object are inferred from the script.main.ts import { compile } from '@maxencerb/evs';import { createPublicClient, http } from 'viem';import { mainnet } from 'viem/chains';16 collapsed lines// script.ts (step 2)import { evscript, t } from '@maxencerb/evs';import { erc20Abi } from 'viem';const poolAbi = [{ type: 'function', name: 'token0', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },{ type: 'function', name: 'token1', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },] as const;const poolTokens = evscript({ name: 'poolTokens', args: [t.address] }, (s, pool) => {const token0 = s.read({ address: pool, abi: poolAbi, functionName: 'token0' });const token1 = s.read({ address: pool, abi: poolAbi, functionName: 'token1' });const symbol0 = s.read({ address: token0, abi: erc20Abi, functionName: 'symbol' });const symbol1 = s.read({ address: token1, abi: erc20Abi, functionName: 'symbol' });return s.return({ token0, token1, symbol0, symbol1 });});const compiled = compile(poolTokens);const client = createPublicClient({ chain: mainnet, transport: http() });const out = await client.readContract({...compiled.toViem(), // { abi, code }: a plain deployless eth_callfunctionName: 'poolTokens',args: ['0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640'], // typed readonly [pool: `0x${string}`]});// out: { token0: `0x${string}`; token1: `0x${string}`; symbol0: string; symbol1: string }
Where next
Section titled “Where next”- Why evs? compares this with multicall and hand-written helper contracts, and says when not to use it.
- Writing scripts covers values, control flow, loops over runtime arrays and the three calling verbs.
- Executing scripts explains the deployless and state-override modes and which providers support each.
- How it works walks through the pipeline from recorded IR to verified bytecode.