Sep 15, 2026
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.
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.
How
Right after the viem PR I started building it by hand.
- Attempt one defined every opcode manually, with the stack tracked in my head. It worked for toy scripts and fell apart as soon as a value had to survive across a call.
- Attempt two abstracted the stack into a pseudo Yul with callbacks. The plan was to abstract memory on top of that, then put the user facing language on top of the memory layer. Reasonable plan, wrong estimate.
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
- TS callbackruns once, on your machinerecord: types, literal checks, source locations
- ScriptIrfrozen JSON statement treevalidateIr: types, scopes, one return · eliminateDeadCode
- AsmNode[]codegen, one slot per valueframe layout, dispatcher, statement templates, call and ABI emitters
- assemble + verifyon every compilePUSH2 fixups, JUMPDEST scan, stack-height simulation, EIP-170
- CompiledEvsScriptbytecode + literal ABIruntimeBytecode, initBytecode, abi, sourceMap, toViem(), disassemble(), explainRevert()
- eth_calldeployless or state overrideany standard RPC, any block, nothing deployed
- typed objectinferred by viemreadContract decodes against the script's own ABI
- Recording. Each builder call appends a statement to a tree over a table of typed values.
Literals are range checked on the spot, so
x.add(300n)on auint8throws at your line. The result is a frozen, JSON-safeScriptIrwith a source location on every statement. - Codegen. Every value has a fixed 32 byte slot in memory. A statement loads its operands, computes, stores the result. No stack scheduling, so the stack is empty between statements.
- Verification. That empty stack is checkable. Every compile runs a
JUMPDESTscan, a stack height simulation over every path, shape lints and the EIP-170 size cap. A failure throws with “bug in evs” in the message.
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.