A Reactive EVM Search Stack in Rust
Five Rust crates for event-driven arbitrage and liquidation search: maintain EVM state from logs, compare executable routes locally, and reject invalid transactions before submission.
These crates grew out of problems I kept hitting in solver and searcher projects: observe a trigger, construct the correct point-in-time EVM state, and search its executable consequences before the opportunity disappears.
I designed, built, and released this five-crate Rust stack to make that loop reusable across my own searcher projects. It covers state preparation, event-driven maintenance, local simulation, and route search; applications supply their strategy, execution, and risk controls.
revm provides an excellent local EVM, but
the caller still has to supply its database and decide whether cached accounts,
code, and storage are safe to use. A missing slot normally causes an obvious
fetch or failure. A stale slot can produce a perfectly valid, perfectly wrong
simulation. foundry-fork-db
helps with missing data, while state lifetime and repeated provider reads remain
problems for the application to solve.
For a searcher, these are trading decisions: stale reserves can make an unprofitable route look attractive, and a stale oracle price can change whether a liquidation is executable. Repeated RPC calls consume the time available to compare responses. The stack loads a working set in bulk, advances supported state from events, and evaluates candidates against the same immutable snapshot. Each candidate gets an isolated overlay and executes the protocol’s real bytecode, including its rejection rules.
The layers are composable. Applications can add event-to-state adapters and input sources, then build higher-level tools over the simulation engine. The AMM search crate supplies a routing interface over AMM state and quotes; a liquidation monitor can combine those layers with oracle adapters and position logic.
Three measurements show the scale: 3.77–8.43ms median for warm heuristic route searches over a 117-pool basket; 1.725–2.076ms for a warm Aave liquidation-and-routing loop; and 121.0ms median to extract the full tick and observation state of three V3 pools in one call. These measure different workloads. The benchmark section identifies their versions, sample sizes, and timing boundaries.
From Cold Start to a Search Result
The lifecycle starts with a small working set and ends with application logic reacting to changes in that state.
1. Establish the state point and load what matters
The application initializes an EvmCache through the balanced Alloy transport,
chooses a canonical block, and supplies cold-start instructions for the accounts,
code, and storage it needs. Domain adapters describe those requirements; the
application decides which pools, feeds, and positions belong in its working set.
For AMMs, the registry discovers or accepts pool registrations, validates their
metadata, and executes their cold-start plans. The V3 helper can generate a
small EVM program and temporarily substitute it for the pool’s code inside an
eth_call. That program scans the tick bitmap and returns the globals,
initialized ticks, and observation ring in one round trip. The override exists
only for that call. Dense pools can use a bounded partial scan when the full
range exceeds the provider’s call-gas allowance.
2. Subscribe to the events that describe the next state
Once the required state is loaded, adapters declare their event interests and the application attaches an input source. WebSocket support is available out of the box, and the source and adapter interfaces can be extended for other feeds or domains. AMM adapters select events such as swaps and liquidity changes; oracle adapters select the feed events relevant to registered sources.
A conventional ERC-20 balance tracker makes the idea concrete. For a known
Solidity balance mapping, an account’s storage key is
keccak256(abi.encode(account, balanceSlot)). Given already loaded balances,
Transfer(from, to, amount) tells the adapter to subtract from the sender and
add to the recipient. It can update both cached values without fetching their
new absolute balances. Mint and burn skip the zero-address leg; an unknown
balance must be loaded before a relative update can be used.
3. Apply a proven transition, or request repair
Each adapter owns the meaning of its events. A V2 Sync carries absolute
reserves. Canonical V3 replay uses complete parent state and ordered event
context to reproduce its declared pricing-state transitions. When the available
evidence cannot establish the next value, the adapter invalidates the affected
state and requests an authoritative read at the exact block hash.
This is also the extension point for application-specific logic: define the state a domain needs, the events that affect it, and which transitions those events can prove.
4. React to the committed change
Consumers can subscribe to committed AMM changes and run their business logic against the accompanying immutable snapshot. A pool update can trigger an arbitrage search; an oracle update can trigger an Aave health-factor check. Candidates receive isolated overlays, so evaluating one route or liquidation does not change the state seen by another. The search crate supplies the route generation and affected-route refresh interface on top of AMM simulation.
Interactive event-to-state architecture
AMM adapter
The adapter replays the pricing-state transition
Canonical V3 replay combines the event with an exact parent snapshot and ordered block context. It reconstructs price, liquidity, fees, observations, and crossed-tick accumulators, then checks the result against the event before emitting one atomic update batch.
Replay inputs
exact parent pricing state
canonical block + ordered log context
Swap {
amount0, amount1,
sqrtPriceX96, liquidity, tick,
}Atomic pricing-state effects
slot0 + active liquidity
global fees + protocol fees
observation ring
crossed-tick accumulators
check final amounts, price,
tick and liquidity against eventThis requires the declared parent cells and a proven runtime family. Missing evidence or a replay mismatch invalidates state and requests repair. Mint, Burn, fee, and observation events have their own transitions.
Hover or focus one component at a time. Its immediate boundaries light up: adapters emit exact writes or ResyncRequired, while EvmCache turns repair into pinned, coalesced work for the RPC provider pool. Data examples are schematic.
Putting It Together: An Aave Liquidation
The Aave V3 demo shows how the layers work together: prepare a warm cache, process an oracle update, identify a liquidatable position, and generate and simulate its liquidation and collateral-unwind route.
In the pinned historical replay, the recorded oracle update and source reconciliation move the position’s health factor from 1.004628 to 0.998193, as reported by Aave’s own bytecode. From that post-update snapshot, the demo simulates the liquidation and searches local WETH/USDT quotes across Uniswap V2, Uniswap V3, and Curve Tricrypto2. It selects Uniswap V3 for the collateral unwind after accounting for repayment and modeled gas.
Once the required state is warm, liquidation simulation and route scoring take 1.725–2.076ms, with zero additional RPC calls in the measured loop. The cache supplies the state read by Aave and the AMMs, letting the application evaluate an executable response locally. This timing covers the local candidate loop; state acquisition and oracle reconciliation sit outside it. The release benchmark notes record the setup and both runs.
The demo uses synthetic local funding and modeled costs for a fixed historical fixture. It simulates the response without submitting a transaction.
Engineering Decisions
Transport and Cache
For my workloads, keeping a narrow working set reduced repeated provider reads without requiring a node for every chain. That choice concentrates effort in knowing which state is needed and when it remains valid. Applications requiring broad, unpredictable state access may prefer a local node; the cache still needs an authoritative provider for cold start and repair.
alloy-transport-balancer handles that network boundary behind an ordinary Alloy
client. It combines batching, weighted endpoint selection, coordinated rate-limit
backoff, and per-provider request and concurrency limits. An HTTP 413 can move
a batch to an endpoint with a larger request ceiling.
evm-fork-cache owns the resulting state and its lifetime. It distinguishes
observed, derived, verified, and invalidated values; freezes snapshots for
workers; and journals reactive changes for reorg recovery. Block-hash checks
prevent delayed reads from being committed to a different state point. Local
calls and bundles use revm, with gas accounting, revert handling, and inspectors.
Speculative simulation with storage validation
FreshnessController::run can start local candidates while a background task
verifies the volatile slots they read. Unchanged results become
ConfirmedStorage; affected candidates are rerun and returned as Corrected.
This confirmation covers tracked storage reads only. Native balances, nonces,
and bytecode require separate validation; the controller does not emit
ConfirmedFull.
Oracle State
A decoded price must reach the storage that downstream protocol bytecode reads.
evm-oracle-state maps supported OCR NewTransmission events into packed
storage writes and exposes typed feed registrations with denomination, round,
staleness, and readiness information.
Events provide the fast path; startup, aggregator changes, reorgs, and periodic checks reconcile against the Chainlink proxy. That keeps the protocol-facing price and the application’s interpretation of the feed under the same authority rules. The crate also exposes opt-in pending oracle-update notifications as speculative evidence, separately from confirmed snapshots.
AMM State
The one-call V3 cold start described above extends Dedaub’s bulk-storage extraction technique. The generated program discovers the pool’s tick topology and observation ring inside the provider’s EVM, avoiding a sequence of dependent client-side reads.
Maintaining that state from logs requires more than copying a swap’s final
price and liquidity. Canonical V3 replay starts from an exact parent snapshot
and ordered block/log context. It reproduces the pricing-state changes to
fees, observations, and crossed-tick accumulators, then checks its result against
the event. Mint, Burn, and the fee and observation events have their own
transitions; Initialize requests cold start.
The transition contract defines the supported state surface and verification evidence. Its tests compare sequences of event-derived state against deployed pool bytecode and execute follow-up swaps. This catches cases where one quote looks correct while an incorrect accumulator would corrupt later simulation. Reviewed Base and Optimism Slipstream deployments have a narrower quote/search guarantee; arbitrary forks and protocol-wide position or reward accounting are outside it.
Search Cost and Route Quality
I chose protocol bytecode for final quote evaluation because fee, rounding,
and tick-crossing behavior can determine whether a marginal route is worth
executing. This makes state readiness and the number of simulations central to
search cost. evm-amm-search therefore spends effort on deciding what to
simulate and what can safely be reused.
The graph preserves parallel venues and each supported token pair. Search can stream heuristic-first candidates and continue into a deduplicated exhaustive remainder within configured bounds. Hop limits and candidate budgets control how much work it can do; an early result need not be the best route in a wider search universe. The crate ranks gross output, while the Aave application adds repayment and modeled gas to its decision.
RouteSearchSession retains candidate paths, quotes, and an affected-pool index.
A pool update invalidates and requotes the candidates that use it. Missing or
degraded state, topology changes, and exhausted refresh budgets request
recomputation. For continuous consumers, LiveRouteRuntime bounds worker
concurrency and checks result identities so an older job cannot overwrite the
answer for newer state.
One optimization targeted repeated graph lookups and connector scoring. I added request-local score caches and a synchronized outgoing-edge view. In a synthetic scheduler probe, average time fell from 19.78ms to 4.63ms across two optimization rounds. The tradeoff is one extra record per directed edge, plus temporary per-request caches. Preserving traversal order and expiring request scores keeps the optimization from silently changing candidate selection or carrying stale liquidity rankings into the next search.
That probe ran 400 warm searches over 320 pools and 32 tokens, with a three-hop limit, deterministic mock quotes, and a debug profile with debug information disabled. It isolates scheduler work; the performance model records each before/after run and the memory cost.
The route-search TUI makes the live behavior visible. This recording shows a 258-pool graph with streaming quotes, gas simulation, alternate paths, and block and log updates.
Performance and Evidence
The September 8 rerun uses published releases, fixed historical blocks, and repeated measurements. Acquisition, search, and application timings remain separate:
| Workload | Result | What was measured |
|---|---|---|
| Cold-loading 117 V2/V3 pools | 1,993.8ms p50; 4,334.9ms p95 | 50 fresh processes, three fixed blocks; excludes discovery, graph construction, liquidity-index refresh, and search priming |
| Warm heuristic route search over 117 V2/V3 pools | 3.77–8.43ms p50 | Range of per-route medians for three token pairs; 300 measured iterations per pair, with zero measured-loop RPC requests |
| Warm Aave liquidation and routing | 1.725–2.076ms | Four repayment candidates and three direct pool routes; two fresh September release runs with zero additional RPC in the measured loop |
| Full-range V3 extraction, three pools in one call | 121.0ms p50; 157.8ms p95 | 90 measured repetitions across three blocks; 2,730 initialized ticks and 3,663 observations in total |
The repeated benchmark report
includes source, locked dependencies, every timing, tails, checksums, and
reproduction commands. It uses evm-amm-search 0.2.0, evm-amm-state 0.3.0,
evm-fork-cache 0.4.0, Rust 1.90, an M1 Pro, and four search workers.
Warm heuristic searches had p50/p95 times of 3.77/4.29ms for WETH→USDC, 8.43/16.51ms for LINK→AAVE, and 7.06/10.88ms for DAI→UNI. All 900 measured heuristic winners matched the exhaustive continuation on this fixture. These are repeated timings of nine route/block combinations, not 900 independent market observations or a general guarantee of optimality. Exhaustive completion has a separate timing boundary in the report.
Full extraction took 74.75ms p50 for the USDC/WETH 0.05% pool by itself,
including all 1,552 initialized ticks and 723 observations at the pinned blocks.
Decoded individual and three-pool batch results matched in all 99 checks,
including warmups. Both paths use one eth_call per extraction.
Acquisition timings include a 20-request/second pacing cap on a gzip-enabled QuickNode archive endpoint. Its response limit rejected a single 117-pool load, so the cold-load run uses batches of at most 16 pools. Local caches start empty; provider-side caching is uncontrolled. These results therefore describe the tested workload and provider configuration. The earlier 997.2ms cold-load and 4.37–9.78ms warm-search observations remain historical evidence, not a directly comparable speedup baseline.
The Aave release runs used all five current registry versions, Rust 1.90, and an M1 Pro. Both reproduced the health factors, rejected candidate, and winning route. Including archive RPC and initialization, total process work was 8.319s and 10.114s. The millisecond result starts after warmup; two runs establish reproducibility of this fixture, not a latency distribution. The repository retains raw logs and checksums.
These workloads do not measure a complete WebSocket-frame-to-ranked-liquidation or event-to-submission span. Application submission, transaction inclusion, and cross-chain settlement have distinct timing boundaries.
Earlier component benchmarks
These measurements predate the September releases and isolate smaller operations:
| Boundary | Observed time |
|---|---|
V2 Sync at adapter ingestion through cache write | 250–285ns |
Balancer V2 Swap through warm cache writes | 430–520ns |
V3 Mint/Burn through the earlier warm tick/liquidity path | ~1.4µs |
| OCR2 event through its storage-write plan | ~253ns |
| Oracle runtime ingestion through a typed feed-change report | 50–240µs |
| Local AMM quote after warmup | 8–85µs |
The AMM nanosecond rows include routing, decoding, arithmetic, and cache mutation
from adapter ingestion. The V3 row predates the new complete canonical
transition. Setup, versions, and commands are in the tagged
AMM benchmarks,
oracle benchmarks,
and cache performance notes.
The September Aave runs separately observed 83–455µs for oracle ingestion and
its feed-change report.
Exploratory comparison with external quote APIs
The 117-pool route sample also compared outputs with LI.FI and 1inch. Those APIs
searched different live, unpinned venue universes, so it cannot establish general
route-quality superiority. Local first quotes arrived in 312–946µs. After
converting LI.FI’s gas estimate to output-token terms, LI.FI won WETH/USDC while
the local routes won LINK/AAVE and DAI/UNI. The
full comparison
preserves the amounts, sign convention, and limitations.
Release Scope and Flashblocks
The five crates are pre-1.0 releases requiring Rust 1.90 or newer and have not received an independent security audit. They cover transport, working-set state, domain adapters, and route search. Applications own transaction construction, submission, keys, and operational risk. General gas-aware and split-route optimization, automatic hydration of every pending transaction, and universal protocol coverage remain outside the release scope. A self-hosted routing sidecar is available in public beta.
Experimental Flashblocks preview support is implemented. The cache and AMM layers turn supported preconfirmed events into isolated, immutable state previews for simulation before canonical confirmation. Previews retain provider and event provenance, reuse the event-to-state adapters, and are replaced or invalidated as their underlying state changes. They remain separate from canonical state and accounting.
My arbitrage bot integrates this capability in observation mode. Preview-driven transaction execution remains disabled while publication, retry, and currentness at submission are validated. The implemented preview capability and its use for live transaction execution have separate acceptance criteria; no execution speedup is claimed here.
Further work includes measuring the complete WebSocket-to-ranked-result span, expanding historical fixtures and protocol coverage, and evaluating larger persistent subscription sets.
Reproduce the Integration
The liquidation showcase contains the fixed fixture, application lockfile, candidate ranking, and a separate realtime health-factor example. With Rust 1.90 and an Ethereum archive RPC that serves the pinned blocks:
git clone --branch v0.1.0 https://github.com/KaiCode2/evm-liquidation-showcase.git
cd evm-liquidation-showcase
cp .env.example .env
# Set E2E_RPC_URL in .env to your archive endpoint.
BENCH_COLD_WARM=1 cargo run --release --lockedThe command runs the historical replay without a signing key or transaction submission. The repository also includes a separate realtime oracle-to-health-factor example.
For an interactive view of the AMM graph, install the versioned TUI and provide an Ethereum HTTP endpoint and a genuine WebSocket endpoint as described in its README:
cargo install evm-amm-search --version '=0.2.0' --locked \
--features live-runtime --bin amm-route-tui
amm-route-tuiAcknowledgements and Influences
The stack builds on revm,
Alloy, and
foundry-fork-db. My contribution
is the working-set lifecycle, domain-state maintenance, and search composition
over those foundations.
Dedaub’s bulk-storage extraction work and storage-extractor informed the call-scoped bytecode technique, extended here to discover V3 tick topology. Artemis influenced the separation of ingestion, strategy, and execution concerns. Fynd, built on Tycho, was a reference for realtime DeFi routing. This stack composes routing over warmed AMM registrations and local EVM execution.
Releases
alloy-transport-balancer 0.3.0evm-fork-cache 0.4.0evm-oracle-state 0.3.0evm-amm-state 0.3.0evm-amm-search 0.2.0, with a self-hosted routing sidecar in public beta
Written by
Kai Aldag
I research on-chain markets and build the systems to trade them. I write about protocol mechanics, execution infrastructure, and what I learn along the way.