leafage-evm is a lightweight EVM executor built with alloy and revm. It focuses on state queries (eth_call, estimateGas, etc.) and does not store transaction data. State updates are received via Kafka + S3, rather than P2P synchronization.
- Two Node Modes:
- State Node: Only retains recent block states (default 64 blocks), ~90GB for ETH mainnet (as of 2025.1)
- Archive Node: Retains complete historical state, ~360GB for ETH mainnet (as of 2025.1)
- Multi-chain Support: Ethereum mainnet, Optimism, BSC, Cosmos EVM
- Multiple Database Backends: RocksDB (default), MDBX
- Database Tooling: Bulk archive initialization, RocksDB/MDBX migration, compaction and rewind
Any EVM-compatible chain can potentially be supported. The following chains are currently supported:
| Method | Description |
|---|---|
eth_call |
Execute a contract call |
eth_multiCall |
Batch contract calls |
eth_blockNumber |
Get current block number |
eth_getBalance |
Get account balance |
eth_getBlockByNumber |
Get block by number |
eth_getBlockByHash |
Get block by hash |
eth_getCode |
Get contract code |
eth_getStorageAt |
Get storage slot data |
eth_getTransactionCount |
Get account nonce |
eth_chainId |
Get chain ID |
eth_baseFee |
Get base fee |
| Method | Description |
|---|---|
version |
Get version info |
getAddressNonce |
Get account nonce |
getAddressBalance |
Get account balance |
getAddressCode |
Get contract code |
getStorageAt |
Get storage slot data |
contractMultiCall |
Batch contract calls |
simulateTransactions |
Simulate transaction execution |
estimateGas |
Estimate gas |
getLatestBlock |
Get latest block |
getBlockByHeight |
Get block by height |
getBlockById |
Get block by hash |
blockIsValid |
Validate block |
Note: Block query methods (
eth_getBlockByNumber,eth_getBlockByHash,getLatestBlock,getBlockByHeight,getBlockById) return header only -transactionsandunclesare always empty. leafage-evm does not store transaction data.
| Method | Description |
|---|---|
pre_traceCall |
Pre-execute call trace |
pre_traceMany |
Batch pre-execute traces |
Requirements: Rust 1.79+
cargo build --releaseDocker build:
docker build -t leafage-evm .RUST_LOG=info ./target/release/leafage-evm standalone \
--db-path /path/to/db \
--listen-addr 0.0.0.0:8545 \
--rpc-addr http://geth:8545 \
--evm-type mainnet \
--chain-cfg 1| Parameter | Default | Description |
|---|---|---|
--db-path |
- | Database path (required) |
--listen-addr |
0.0.0.0:8545 | RPC listen address |
--rpc-addr |
- | Geth RPC address (for HTTP mode state updates) |
--evm-type |
mainnet | EVM type: mainnet/op/bsc/cosmos |
--chain-cfg |
1 | Chain ID |
--db-type |
rocksdb | Database type: rocksdb/mdbx |
--db-cache |
2048 | Database cache size (MB) |
--diff-depth-limit |
64 | Block diff depth retained in memory |
--catchup-safe-depth |
0 | S3 catch-up reorg buffer: blocks below the Kafka head backfilled via the exact parent-hash chain instead of the by-number index. 0 disables it (legacy behavior); set above the chain's max reorg depth (e.g. 64 for Moonriver) |
--bundle-range-size |
32 MiB | Target size for combining multiple StateDiff entries into one S3 Range request. A larger single entry is still read alone. Also available on archive-init |
--archive |
false | Enable archive mode |
--prometheus-addr |
- | Prometheus metrics address |
--kafka-s3-config |
- | Path to Kafka + S3 config JSON file |
--max-connections |
5000 | Maximum concurrent RPC connections |
--rpc-timeout |
10000 | RPC request timeout (ms) |
--iterator-timeout-secs |
0 | Iterator timeout for archive mode (0 = disabled) |
--historical-rpc |
- | Historical RPC endpoint for pre-fork queries (e.g., OP pre-bedrock) |
--historical-height |
- | Fork height threshold for historical RPC forwarding |
When using Kafka + S3 mode, provide a JSON config file:
{
"topic": "block-notifications",
"brokers": "kafka1:9092,kafka2:9092",
"partition": 0,
"bucket_name": "state-diffs-bucket",
"bundle_bucket_name": "compacted-state-diffs-bucket",
"outer_bucket_name": "block-info-bucket",
"offset_dir": "/path/to/offset",
"s3_chain_id": "1",
"version": "v1",
"s3_read_timeout_secs": 20
}bundle_bucket_name is optional; omit it or leave it empty to keep the legacy per-block S3 path.
Both standalone and archive-init accept the optional --statediff-key flag with values state-root or block-hash. For example, use standalone --statediff-key block-hash when consuming Arb Classic data produced with block-hash keys, or archive-init --statediff-key block-hash for archive initialization with the same source bucket, S3 chain ID and version. This is a top-level CLI option, outside the Kafka + S3 JSON configuration. The choice applies to genesis, live consumption, catch-up, restart and parent-hash backfill. Kafka messages are unchanged.
When omitted, the historical default is preserved: S3 chain ID "999" uses block hashes; all others use state roots. Explicit values override this default, including on chain 999. The resolved strategy is logged at startup. Match the producer's setting and keep it consistent when resuming the same dataset; chain ID 42161 does not distinguish Classic from Nitro.
Block-hash mode requires a source diff for every block, including genesis and blocks with unchanged/zero state roots. Missing or malformed objects fail the read without falling back to a state-root key. Bundle entries marked as synthesized are rejected before applying any block in the requested range; disable bundle reads to read the original per-block objects, or regenerate the bundle with real diffs. Roots inside each diff remain state roots.
s3_read_timeout_secs is a positive integer, defaulting to 20. It sets the AWS SDK operation timeout for GET and LIST calls, including bundle GET/range requests and SDK retries. Response body consumption after send() returns is outside this timeout; the SDK's existing stalled-stream protection remains unchanged. Errors propagate to the existing caller handling. archive-init uses the same default 20-second operation timeout.
Both standalone and archive-init accept --bundle-range-size <MIB> to tune the compacted StateDiff request size. The limit applies only when grouping multiple entries; a single entry larger than the configured value is fetched by itself.
| Subcommand | Purpose |
|---|---|
standalone |
Start the node |
archive-init |
Initialize an archive database in bulk from S3 and RPC |
db-migrate |
Migrate between databases (RocksDB ↔ MDBX, archive → state) |
compact |
Compact the database to reclaim space |
force-compact |
Force bottommost compaction so bulk-loaded SSTs regenerate the prefix bloom / partitioned index |
rewind |
Reset the committed head to an earlier block and resync from S3 |
archive-scan |
Read-only scan of an archive column family (forensics/debugging) |
Run leafage-evm <subcommand> --help for the full parameter list.
# Initialize an archive database
RUST_LOG=info ./target/release/leafage-evm archive-init --help
# Migrate a database (e.g. archive → state)
RUST_LOG=info ./target/release/leafage-evm db-migrate \
--src /path/to/source/db \
--dst /path/to/leafage/dbleafage-bench is a CLI tool for benchmarking eth_call performance between leafage-evm and geth.
cargo build --release -p leafage-benchThe benchmark corpus (bin/leafage-bench/corpus/corpus.json) is tracked via Git LFS. Pull it after cloning:
git lfs pull./target/release/leafage-bench run \
--corpus bin/leafage-bench/corpus/corpus.json \
--target http://leafage-evm:8545 \
--compare http://geth:8545| Parameter | Default | Description |
|---|---|---|
--corpus / -c |
- | Path to the corpus JSON file (required) |
--target |
- | Primary RPC endpoint URL (leafage-evm) (required) |
--compare |
- | Comparison RPC endpoint URL (geth) |
--label |
all | Only run cases with this complexity label: L1, L2, L3 |
--concurrency |
10 | Number of concurrent requests per endpoint |
--requests |
corpus size | Total requests per endpoint per round |
--rounds |
1 | Number of benchmark rounds |
--seed |
- | Shuffle seed for corpus ordering |
--output-dir |
- | Directory for export files (summary.json, verbose.json) |
--verbose |
false | Write per-request details to verbose.json (requires --output-dir) |
Print summary statistics of the corpus without running any benchmark:
./target/release/leafage-bench inspect \
--corpus bin/leafage-bench/corpus/corpus.json| Document | Description |
|---|---|
| Architecture.md | System architecture, crate structure, key traits |
| StateManage.md | In-memory state tree, fork handling, finalization |
| StateUpdater.md | Kafka + S3 and HTTP update modes |
| Database.md | RocksDB storage layout for state and archive nodes |
| DataSpec.md | Data format specification for state updates |
| Deploy | Deployment guide with Docker Compose |
leafage-evm manages state using a linked-list structure:
Latest Block (Head)
↓
Block N-1 diff
↓
...
↓
Block N-63 diff
↓
Base State (RocksDB)
- Recent 64 block diffs are kept in memory for fast access
- State queries search from top to bottom, falling back to RocksDB
- On new block: push new diff to head, persist oldest diff to RocksDB when exceeding depth limit
leafage-evm supports two modes for receiving state updates:
- Kafka + S3 (Primary): Receives block change notifications via Kafka, fetches block info and state diffs from S3
- HTTP (Fallback): Polls
trace_debankBlockRPC from a modified Geth instance
leafage-evm/
├── bin/leafage-evm/ # CLI entry point
├── crates/
│ ├── leafage-evm-types/ # Type definitions
│ ├── leafage-evm-storage/ # Storage layer (RocksDB/MDBX, StateTree)
│ ├── leafage-evm-rpc/ # JSON-RPC implementation
│ └── leafage-evm-chains/ # Chain-specific logic (BSC/Cosmos precompiles)
└── docs/ # Documentation
MIT OR Apache-2.0
For historical Classic calls, use --evm-type=arbitrum --evm-custom-config='{"execution_mode":"classic"}'. See Classic execution scope and verification for StateDiff configuration, supported calls and the limits of account-state-only simulation. Nitro remains the default.