Building evs, a TypeScript to EVM compiler for on-chain reads

A two and a half year old itch, a viem pull request, several abandoned compilers, and the one that finally shipped: TypeScript callbacks that run as EVM bytecode inside a single eth_call.

Ethereum Virtual Script (evs)

I just released evs. You write a TypeScript callback, evs compiles it to EVM bytecode and runs it through one eth_call. You get a typed object back, and viem infers the type from the script itself.

The idea is more than two years old. This is the story of where it came from, why it took so long, what it does and what I want to do next.

Why

I came to DeFi as a fullstack developer and ended up writing smart contracts. Along the way I did research on the EVM, learned every opcode, and spent a lot of time thinking about how the machine works and how TypeScript could run on it.

In early 2024 I opened a pull request on viem that added state overrides to eth_call. State overrides let you replace the balance, the code or the storage of any account for the duration of a call. The example in the PR was a swap simulation with a faked allowance, and that is exactly the kind of thing evs does today: a script that grants the allowance, makes the call and returns the result, in one eth_call. The idea went further though, to any chain of dependent reads. It was a manual PR at the time.

Three things kept the itch alive.

Custom read code lives in the wrong place. Every protocol ends up with a “lens” contract that aggregates reads for the frontend. Thanks to state overrides it does not even need a deployment, and today anyone can vibe code one. But it is still Solidity, in another repo, and the frontend gets an ABI with no real types.

Small dapps have no backend. They talk to a public RPC directly, and every screen is a waterfall of dependent calls. Multicall batches one level, but it cannot feed the output of one call into the next. A few hundred milliseconds per trip and the app feels broken.

Curiosity. I knew the machine, I wrote TypeScript all day, and I wanted to see the second run on the first.

Underneath all three is an old idea from classic backend work: colocation. You run the code close to the database so reads are fast. Here the database is the chain state. Code running on the client is as far from it as you can get, every read is a network round trip. Code running on the EVM inside the node is as close as you can get. A script that runs there does its reads next to the data and sends back one answer.

Todayone round trip per dependency level0 round trips
app
pool.token0()
0xA0b8…eB48
wait, decode, encode the next call
multicall: token0.symbol(), token0.decimals()
"USDC", 6
node
With evsthe data flow runs on the node0 round trip
app
eth_call(script, args)
{ token0, symbol, decimals }
node · evm
pool.token0()
token0.symbol()
token0.decimals()
Dependent reads: two round trips against one, with the glue moved into the node.

How

Right after the viem PR I started building it by hand.

I spent evenings reading assembly listings to find the one SWAP that was off by one. I tested against complex Solidity codebases. I wrote optimizers that took seconds to compile a few dozen lines, then threw them away.

Then the type system. Half the value of the project is that a script should be typed like a viem readContract: the ABI types the arguments, a call result is a typed handle you can pass to the next call, and the final result is a real TypeScript object. That is a mountain of conditional types on top of a compiler that was already too big for a side project. I stopped.

It sat in a drawer until I started using Claude for real work and the size of the thing looked manageable again. I kept the core idea and rewrote the design around it: a proper intermediate representation, a full type system, and viem interoperability as a hard requirement. After a couple of wrong attempts the architecture settled on something deliberately boring. Every value gets a fixed memory slot, there is no stack scheduling, and the output is verified on every compile. Boring is what made it testable.

What

The quick start script reads a Uniswap V3 pool’s tokens, then each token’s symbol, a defaulted decimals, and a balance. Seven dependent reads, one eth_call.

import { evscript, t } from '@maxencerb/evs';
import { createPublicClient, erc20Abi, http } from 'viem';
import { mainnet } from 'viem/chains';
import { uniswapV3PoolAbi } from './abis';

const poolMeta = evscript(
  { name: 'poolMeta', args: [t.address, t.address] },
  (s, pool, user) => {
    const token0 = s.read({ address: pool, abi: uniswapV3PoolAbi, functionName: 'token0' });
    //    ^? Expr<'address'>
    const token1 = s.read({ address: pool, abi: uniswapV3PoolAbi, functionName: 'token1' });
    const slot0 = s.read({ address: pool, abi: uniswapV3PoolAbi, functionName: 'slot0' });
    const symbol0 = s.read({ address: token0, abi: erc20Abi, functionName: 'symbol' });
    const symbol1 = s.read({ address: token1, abi: erc20Abi, functionName: 'symbol' });
    const dec = s.tryRead({ address: token0, abi: erc20Abi, functionName: 'decimals' });
    const decimals0 = s.select(dec.success, dec.value, 18);
    const bal0 = s.read({ address: token0, abi: erc20Abi, functionName: 'balanceOf', args: [user] });
    return s.return({ token0, token1, symbol0, symbol1, tick: slot0[1], decimals0, bal0 });
  },
);

const client = createPublicClient({ chain: mainnet, transport: http() });
const out = await client.readContract({
  ...poolMeta.compile().toViem(),
  functionName: 'poolMeta',
  args: ['0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640', '0x0000000000000000000000000000000000000001'],
});
// out: { token0: `0x${string}`; token1: `0x${string}`; symbol0: string; symbol1: string;
//        tick: number; decimals0: number; bal0: bigint }

The callback runs once, on your machine, and never sees a real address. token0 is an Expr<'address'>, a placeholder for a value that only exists inside the node. Passing it as the address of the next read is what moves the data flow on-chain. The one thing to unlearn: a native if on an Expr does nothing, so runtime branching goes through s.if, s.forEach and s.select.

From callback to bytecode

  1. TS callback
    runs once, on your machine
    record: types, literal checks, source locations
  2. ScriptIr
    frozen JSON statement tree
    validateIr: types, scopes, one return · eliminateDeadCode
  3. AsmNode[]
    codegen, one slot per value
    frame layout, dispatcher, statement templates, call and ABI emitters
  4. assemble + verify
    on every compile
    PUSH2 fixups, JUMPDEST scan, stack-height simulation, EIP-170
  5. CompiledEvsScript
    bytecode + literal ABI
    runtimeBytecode, initBytecode, abi, sourceMap, toViem(), disassemble(), explainRevert()
  6. eth_call
    deployless or state override
    any standard RPC, any block, nothing deployed
  7. typed object
    inferred by viem
    readContract decodes against the script's own ABI
build time: everything above the artifactrun time: the eth_call, on the node
Every stage produces something you can print, diff and snapshot.

The listing below is the start of a one line script that reads a token’s totalSupply: 247 bytes of runtime code, each statement annotated with its source line.

0x0000  60c0        PUSH1 0xc0  ; frameEnd
0x0002  6040        PUSH1 0x40
0x0004  52          MSTORE  ; free-ptr init
0x0005  6004        PUSH1 0x04
0x0007  36          CALLDATASIZE
0x0008  10          LT
0x0009  6100e8      PUSH2 0x00e8 → @badcd
0x000c  57          JUMPI
0x000d  5f          PUSH0
0x000e  35          CALLDATALOAD
0x000f  60e0        PUSH1 0xe0
0x0011  1c          SHR
0x0012  6362400e4c  PUSH4 0x62400e4c  ; selector supplyOf(address)
0x0017  14          EQ
0x0018  610020      PUSH2 0x0020 → @main
...

Same shape as a solc contract: selector dispatch, calldata floor check, then the argument masked and stored. Which is what you want when you are the one debugging it.

The type system

The part that made me quit the first time is the part I like most now.

A script is its own ABI. poolMeta.abi exists before you compile, as a literal type: one view function whose output is a named tuple built from your s.return. viem infers an object from it. No as const, no codegen step, no ABI file to keep in sync.

Calls are typed like readContract. The ABI types the arguments, Expr handles included. Mutability is enforced per verb: s.read compiles to STATICCALL and only accepts view functions, s.call opens a real CALL frame for things like a quoter, s.simulate dry runs a write and reads back its return value. The wrong verb is a type error.

Errors match like Rust. You declare custom errors on the script, throw them with s.throw, and decode them on the client with an exhaustive match:

const NoBalance = t.error('NoBalance', [namedArg('balance', t.uint256)]);
const NotOwner = t.error('NotOwner');

const guard = evscript(
  { name: 'guard', args: [t.uint256], errors: [NoBalance, NotOwner] },
  (s, x) => {
    s.if(x.lt(10n), () => {
      s.throw(NoBalance, { balance: x });
    });
    return s.return({ doubled: x.mul(2n) });
  },
);

try {
  await client.readContract({ ...compile(guard).toViem(), functionName: 'guard', args: [5n] });
} catch (e) {
  const message = matchScriptError(compile(guard), e, {
    NoBalance: ({ balance }) => `insufficient balance: ${balance}`,
    NotOwner: () => 'caller is not the owner',
    _: (other) => `unexpected revert: ${other.name}`,
  });
}

Throwing an undeclared error is a type error. Adding an error without a handler is a type error. The _ arm gets panics, bubbled callee reverts and unknown selectors. A caught value with no revert data at all, a timeout say, is rethrown instead.

Loops and trust

Scripts are real programs, so they loop. This is the multicall replacement: a runtime address[], a forEach over it, a tryRead per token so a non-token address yields 0 instead of reverting the batch.

const balances = evscript(
  { name: 'balances', args: [t.array(t.address), t.address] },
  (s, tokens, owner) => {
    const balances = s.newArray(t.uint256, tokens.length());
    s.forEach(tokens, (token, i) => {
      const r = s.tryRead({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [owner] });
      balances.set(i, s.select(r.success, r.value, 0n));
    });
    return s.return({ balances });
  },
);

There are also typed subroutines with s.fn, checked arithmetic with solc 0.8 panic codes, structs, keccak256 and ABI encoding, and nested simulations of writes.

This is a compiler emitting bytecode that runs against real money, so the test suite pins every script against an independent interpreter over the IR, against viem’s codecs byte for byte, and against real solc output where a Solidity equivalent exists. Every listing in the docs is regenerated in CI.

You can try it in the browser at the playground.

What I want to do next

Speed, bundle size, and the bytecode. The optimizer is opt in and tiny: a peephole pass and a liveness based slot allocator. There is no stack scheduling. Both the compile time and the emitted code can shrink without giving up the verifiers.

A build step. A script’s output is deterministic, so a Vite plugin could compile every script at build time and ship only bytecode and ABI. The compiler leaves the client bundle and the compile time leaves the client. This is the change that makes evs a no brainer for the small dapp from the first section.

Errors as part of the type. evs is built on viem and I am glad it is, but one thing frustrates me: readContract returns T and throws unknown. The error union of a call is knowable from the ABI and should be part of the return type, the way a Rust Result<T, E> carries both. matchScriptError is a workaround for a property the client should have.

Something bigger on top. If errors are part of the type and scripts are the unit of reading state, a client built around those two ideas starts to look like its own thing rather than a wrapper over viem. With Effect v4 landing, the pieces are there. Not a promise. But the thought has crossed my mind more than once.

The code is on GitHub, MIT licensed, and the docs are at evs.maxencerb.com. If you build something with it, or break it, I would like to hear about it.

← All articles