Skip to content
LogoLogo

Running a local JSON-RPC server

Most tevm commands are one-shot: they start an EVM, run, and exit. tevm serve is the exception. It keeps a Tevm memory client alive and exposes it over HTTP JSON-RPC, so anything that speaks Ethereum RPC — viem, ethers, cast, a browser wallet, your frontend's dev build — can point at it.

Unlike a session, a running server keeps full transaction history for as long as it lives.

Start it

tevm serve --port 8545 --host localhost

Forked against a live network, pinned to a block:

tevm serve \
  --fork https://mainnet.optimism.io \
  --fork-block-number 128000000 \
  --port 8545 \
  --verbose
FlagDefaultDescription
--port <number>8545Port to listen on.
--host <string>localhostHost to bind to.
--fork <url>URL of the network to fork.
--chain-id <string>900Chain id to report. 900 is the Tevm default chain.
--fork-block-number <string>latestFork block: a decimal number or a block tag.
--logging-level <string>infoNode logging level.
--verbosefalseLog every JSON-RPC request and response.

Running without --json gives you an interactive terminal UI with tabs for Call, GetAccount, SetAccount, and a live request log — useful while debugging a frontend against the node.

Talk to it with viem

import { createPublicClient, createTestClient, http, parseEther } from 'viem'
import { optimism } from 'viem/chains'
 
const transport = http('http://localhost:8545')
 
const publicClient = createPublicClient({ chain: optimism, transport })
const testClient = createTestClient({ chain: optimism, mode: 'anvil', transport })
 
const account = '0x1000000000000000000000000000000000000001' as const
 
await testClient.setBalance({ address: account, value: parseEther('100') })
 
const balance = await publicClient.getBalance({ address: account })
const blockNumber = await publicClient.getBlockNumber()
 
console.log(`balance: ${balance}`)
console.log(`block:   ${blockNumber}`)

Talk to it with ethers

import { JsonRpcProvider, formatEther } from 'ethers'
 
const provider = new JsonRpcProvider('http://localhost:8545')
 
const network = await provider.getNetwork()
const blockNumber = await provider.getBlockNumber()
const balance = await provider.getBalance('0x1000000000000000000000000000000000000001')
 
console.log(`chain id: ${network.chainId}`)
console.log(`block:    ${blockNumber}`)
console.log(`balance:  ${formatEther(balance)} ETH`)

Talk to it with curl

The endpoint is plain JSON-RPC 2.0 over POST /:

curl -s http://localhost:8545 \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'

Tevm's own methods work over the same endpoint:

curl -s http://localhost:8545 \
  -H 'Content-Type: application/json' \
  -d '{
    "jsonrpc":"2.0","id":1,"method":"tevm_setAccount",
    "params":[{"address":"0x1000000000000000000000000000000000000001","balance":"0xde0b6b3a7640000"}]
  }'

Request bodies are capped at 1 MiB. A tevm_loadState payload larger than that will be rejected — use tevm load-state against a session instead.

Using it in CI

Start it in the background, wait for it to answer, run your tests, then kill it:

set -euo pipefail
 
tevm serve --port 8545 &
server_pid=$!
trap 'kill "$server_pid"' EXIT
 
for _ in $(seq 1 30); do
  if curl -sf http://localhost:8545 \
      -H 'Content-Type: application/json' \
      -d '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' >/dev/null; then
    break
  fi
  sleep 1
done
 
npm test

Poll for readiness rather than sleeping a fixed interval — a forked start has to reach the upstream RPC first, and how long that takes is not yours to predict.

serve or session?

tevm serve--session
ProcessLong-livedOne per command
Transaction historyKeptNot persisted
ConsumersAnything speaking JSON-RPCThe tevm CLI
Best forFrontends, test suites, other toolingShell scripts, CI steps, one-off queries

See also