Skip to content

Ethereum Virtual Script

Write read-only EVM scripts in TypeScript. evs compiles them to bytecode and runs them as one 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.

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.

  1. Describe the contracts. Plain as const ABI fragments, the same shape viem uses. viem already ships erc20Abi, 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;
  2. 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 the eth_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 });
    });
  3. Compile it and run it with viem. compile produces verified runtime bytecode plus a literal-typed ABI. toViem() hands both to readContract as a deployless eth_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_call
    functionName: 'poolTokens',
    args: ['0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640'], // typed readonly [pool: `0x${string}`]
    });
    // out: { token0: `0x${string}`; token1: `0x${string}`; symbol0: string; symbol1: string }
  • 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.