evm-amm-state
evm-amm-state is a real-time AMM state engine built on a forked-EVM state
cache (evm-fork-cache). It tracks a working set of pools, cold-starts
their on-chain state into the cache, and keeps them current from chain log
events: events that carry absolute state (Uniswap V2 / Solidly Sync) are
applied as exact writes with no RPC at all, Uniswap V3 Mint/Burn and
Balancer vault Swaps are event-sourced onto warm tick / cash slots the
same way, and only genuinely cold slots and delta-only events (Curve, Balancer
joins/exits) turn into a bounded, hash-pinned storage resync (block trace
first, then bulk-storage / point-read fallback). Once a pool's quote read-set is warmed and current, swap
simulations run fully offline against the live-synced state.
The defining design choice: no reimplemented AMM math. Every quote runs the
protocol's canonical on-chain quote entrypoint inside a local revm against the
warmed cache — the pool's own get_dy / getAmountOut, or the protocol's
official router/quoter (Uniswap QuoterV2 / Router02) — then decodes the
result. There is no LocalAMM/amm-math formula layer to drift from the real
contracts.
Installation
All five protocol adapters are enabled by default; trim to what you use with feature flags:
[]
= { = "0.1", = false, = [
"uniswap-v3",
"curve",
"live-runtime", # optional Tokio cache actor + Alloy subscriber driver
] }
Requires Rust 1.88+ (the declared MSRV, checked in CI). The two public
dependencies whose types appear in this crate's API are re-exported at the
crate root — import evm_amm_state::evm_fork_cache and
evm_amm_state::alloy_primitives instead of pinning them yourself, and the
versions always match. evm-fork-cache is a 0.x companion released in
lockstep: a breaking bump there is a breaking bump here.
The pipeline
Each protocol is a single AmmAdapter implementation; the
AdapterRegistry dispatches by pool key.
| Stage | What it does |
|---|---|
| Register | Describe a pool: a PoolKey + ProtocolMetadata (tokens, fee, storage layout / coins, …). |
| Cold-start | registry.cold_start(pool, cache, policy) warms the pool's read-set into the EvmCache from forked storage. Named-slot protocols (Uniswap V2/V3, Solidly) warm known slots; layout-free protocols (Balancer, Curve) discover → verify the exact slots a quote call SLOADs. Known AMM runtime bytecodes can be seeded first and verified once by evm-fork-cache against on-chain EXTCODEHASH. |
| Subscribe | adapter.event_sources(pool) lists the log topics to subscribe to over a wss:// endpoint. |
| React | Decoded logs flow through AmmSyncEngine / AmmReactiveHandler + the evm_fork_cache reactive runtime. Exact-write protocols update cached state with no RPC; protocols whose logs do not carry final storage values emit hash-pinned resync requests that evm-fork-cache resolves from block traces, bulk storage, or point-read fallback. |
| Simulate | adapter.simulate_swap(pool, cache, token_in, token_out, amount_in, &config) executes the pool's own quote against the cached state and returns a SwapQuote — fully offline. |
Supported protocols
| Protocol | Feature | Quote entrypoint | Cold-start | Reactive |
|---|---|---|---|---|
| Uniswap V2 | uniswap-v2 |
Router02.getAmountsOut |
named slots | Sync → exact masked write |
| Uniswap V3 family (V3, PancakeSwap V3, Slipstream) | uniswap-v3 (pancake-v3, slipstream) |
QuoterV2.quoteExactInputSingle |
slot0 + liquidity + multi-word tick scan (per-pool radius), or the one-shot full-range program sync (v3_sync) |
Swap → slot0/liquidity; Mint/Burn → exact tick + global-liquidity writes where warm, resync only cold ticks |
| Balancer V2 | balancer-v2 |
Vault.queryBatchSwap |
discover → verify (getPoolTokens), verify-only once known |
Swap → exact 112-bit cash writes where warm, resync fallback; PoolBalanceChanged → resync |
| Solidly V2 (Aerodrome / Velodrome) | solidly-v2 |
pool getAmountOut |
named slots (config layout) | Sync → two exact slot writes |
| Curve (StableSwap, StableSwap-NG, CryptoSwap v2, Tricrypto-NG) | curve |
pool get_dy |
discover → verify (get_dy read-set) |
TokenExchange + liquidity events → slot resync |
All protocol adapters are on by default; pancake-v3 and slipstream are
thin aliases of uniswap-v3 (one V3-family adapter serves all three). See
docs/protocol-support-matrix.md for the
per-protocol capability matrix (offline-after-cold-start, exact-write vs resync,
discovery, and known limitations), and docs/curve-adapter.md
for the Curve adapter in depth.
Slipstream quoting caveat. Slipstream / Aerodrome CL ships as discovery + cold-start: its own quoter ABI differs (int24 tickSpacing), so discovered registrations leave
feeunset andsimulate_swapreturnsMissingMetadatauntil you supply a Uniswap-compatible quoter + fee — see the support matrix.
Solidly offline caveat. Solidly's
getAmountOutreads more than the reserves its cold-start warms — the pool'sstableflag and tokendecimals, plus an externalIPoolFactory(factory).getFee()STATICCALL (which needs the factory's code and fee slots). With a live-backed cache these fetch lazily on the first quote; for fully-offline Solidly quotes, keep a backend attached or pre-warm that read-set. Uniswap V2/V3 and Curve cold-starts already cover their quote read-set (V3 within its warmed tick window).
Verified Pool Bytecode Seeding
Known pool runtime bytecodes live in src/adapters/bytecodes
as embedded binary artifacts. Before cold-start, an adapter's code_seeds returns
canonical AdapterCodeSeeds built by plain functions: uniswap_v2_pair_code_seed
returns the shared pair runtime and its precomputed code hash, and
v3_code_seed_from_metadata renders the V3 pool template from a pool's immutables.
No per-pool bytecode fields are attached to metadata.
When the cache has an account-fields fetcher available (and seeding is enabled),
AdapterRegistry::cold_start writes these seeds into EvmCache with
seed_account_code; evm-fork-cache verifies the code hash once against the
chain. Seeding is a pure optimization: a pool's own runtime code is otherwise
lazily fetched at first simulate. A code-hash mismatch (or an unverifiable seed)
is purged and the pool falls back to lazily fetching its real code — it stays
Ready, is never left Degraded, and no repair is raised. Verification results
are surfaced on the cold-start report's code_seeds field. Disable seeding
entirely with AdapterRegistry::with_code_seeding(false).
Uniswap V3 has an embedded pool template and an explicit uniswap_v3_code_seed
helper for callers that already know the pool immutables. Factory-discovered
Uniswap V3 registrations carry the factory immutable in metadata, allowing
automatic V3 seeding without assuming a chain-global factory address. Bytecode
seeding covers Uniswap V2 and the V3 family from embedded/rendered templates.
Balancer and Curve pools have no shared template, so by default they fetch their
runtime code lazily on first simulate — but Curve accepts an optional
caller-supplied seed via CurveMetadata::with_code_seed(runtime) for callers
that already know a pool's Vyper runtime (verified once against on-chain code,
same purge-on-mismatch contract). Since seeding is a pure optimization over that
lazy fetch, this is only a latency difference, never a correctness one.
Factory-backed Discovery
PoolDiscovery can build cold-start-ready registrations from configured
factories instead of requiring callers to paste pool addresses. FactoryConfig
is empty by default: callers opt in with explicit factory addresses, e.g.
FactoryConfig::default().with_uniswap_v3_factory(factory), so chain- and
fork-specific deployments never inherit an assumed factory.
Discovery ships for every protocol whose pools resolve through the pinned cache as a derived factory storage slot, batched by default:
| Protocol | Mechanism |
|---|---|
| Uniswap V3 / Sushi V3 / Pancake V3 | fee-keyed getPool[t0][t1][fee] (via ClFactorySpec) |
| Slipstream / Aerodrome CL | tickSpacing-keyed getPool[t0][t1][tickSpacing] (via ClFactorySpec) |
| Uniswap V2 | getPair[t0][t1] |
| Solidly V2 (Aerodrome / Velodrome) | getPool[t0][t1][bool stable] (stable + volatile) |
V3-discovered registrations carry V3Metadata.factory, which gives bytecode
seeding the factory immutable it needs to render and verify the expected pool
runtime. Multiple factories of the same protocol coexist (keyed by
(protocol, factory_address)).
A few AMM shapes are deliberately left to the integrator in 0.1.0 — Algebra-style
CL forks (Camelot, QuickSwap: a different pool engine, not just a discovery
config), Curve (needs the Vyper MetaRegistry view call, not wired this
release — its adapter still simulates explicitly-registered pools), and
Balancer V2 discovery (no on-chain token→pool index, so it needs an async log
scan). These have first-class, stable escape hatches:
register_adapter(Arc<dyn AmmAdapter>) adds a novel simulation engine and
PoolDiscovery::with_factory(Box<dyn PoolFactory>) adds a novel discovery
mechanism — a new AMM never requires forking the crate. See
docs/pool-discovery.md for the full coverage table,
adding your own CL fork, and this boundary in depth.
The whole surface is one method — discovery.find(cache, query) — over one
fluent PoolQuery:
// one token pair, across every matching factory (V2, V3, forks)
discovery.find?;
// every pool joining any pair of a token basket — the C(n, 2) combinations
discovery.find?;
// an explicit set of pairs
discovery.find?;
// scope any of the above to one protocol
discovery.find?;
// mix protocols across pairs in ONE batched read — e.g. some pairs only on V2,
// others only on V3 — with find_many
discovery.find_many?;
An unscoped query spans every matching factory and resolves the whole thing
— all pairs, all factories, all V3 fee tiers — in a single batched read
(AdapterCache::read_storage_slots, one bulk eth_call on EvmCache), so
request count scales with the number of factories, not with pairs, fee tiers, or
basket size (a 5-token mainnet basket resolves 49 pools in one round-trip, ~20×
faster than per-pair scans; see
examples/token_basket_bench.rs). .on(p)
filters to protocol p and errors DiscoveryError::MissingFactory(p) when no
factory is registered for it (an unscoped query never does). External factories
that only implement find_pools (no candidate_reads) keep working through a
per-pair fallback.
Fast multi-pool bootstrap
AdapterRegistry::cold_start_many(pools, cache, provider, policy) warms many
pools at once: it seeds + verifies all one-shot-eligible pools' code in one
account-fields call, hydrates them through a single bundled run_storage_programs
eth_call (V3 full-sync / V2 flat-slot / Balancer or Curve discovered
read-set), and finalizes them Ready, falling back per pool to the conservative
per-pool cold_start for anything without a one-shot program or whose hydration
fails. supports_one_shot_hydration reports which pools take the fast path — a
Curve pool qualifies once its discovered_slots read-set is known (from a prior
discovery, a trace, or a registry), joining V2/V3 in the same bundled call. Combined with token-basket discovery,
the happy path is find(PoolQuery::basket(..)) → cold_start_many → register,
with request count driven by bootstrap phases rather than pool count.
Extending with a new AMM
You can add a brand-new AMM from outside the crate — no fork, no src/ edit —
via the Custom protocol id / pool key / metadata hatches and a minimal
AmmAdapter (only protocol() + simulate_swap()). See the runnable,
self-contained demo examples/custom_adapter.rs
(cargo run --example custom_adapter) and the guide
docs/writing-an-adapter.md.
The V3 cold-start tick-scan radius is per-pool configurable via
V3Metadata.warm_word_radius (default ±2 tick-bitmap words).
Quickstart
Register a pool, cold-start it into a forked cache, and simulate a swap entirely offline once warmed:
use Arc;
use ;
use AnyNetwork;
use ;
use ;
use EvmCache;
use ;
use V3StorageLayout;
async
The full cold-start → WebSocket subscribe → react → simulate loop is in
examples/adapter_pipeline.rs:
ETH_WS_URL=wss://your-node
# or derive wss:// from an https endpoint:
E2E_RPC_URL=https://your-archive-node
Live sync should use AmmSyncEngine or, if wiring the upstream runtime
manually, ReactiveRuntime::ingest_batch_with_resync. Plain ingest_batch
decodes and applies direct writes only; it reports Balancer/Curve/V3 repair
requests without executing the trace/storage resync phase. The boundary and
fallback policy are documented in
docs/trace-backed-sync.md.
The tracked working set changes transactionally without rebuilding the runtime.
AmmSyncEngine::add_pools / remove_pools return generation-scoped
AmmLifecycleReports while preserving journals and unrelated pending work;
register_pools / unregister_pools remain compatibility projections. Pool
handlers use an exact upstream route index, so direct/indexed dispatch does not
scan unrelated pools. remove_pools_evicting is explicit and ownership-aware:
it purges only state exclusive to removed pools, sparing co-tenants such as the
Balancer vault, and fences retained rollback history so a late reorg cannot
restore an explicitly evicted generation. Teardown batches exact resync-ID
cancellation into one pending-queue pass. Adapter families have matching add,
default-rejecting remove, and explicit cascade APIs. See
docs/live-runtime-lifecycle.md.
With the opt-in live-runtime feature, AmmRuntime owns the thread-local cache
inside a Tokio LocalSet and publishes immutable Send + Sync snapshots to
search workers. Startup is anchored by a sealed full header; zero-log blocks,
degradation, and reorg replacements remain version-coherent. The attached Alloy
driver stages generation-owned subscriptions before topology publication and
hash-pins a bounded union of eth_getLogs filters at every header. Reliable
changes, latest snapshot/status watches, lossy diagnostics, bounded queues,
typed backpressure, and prompt shutdown are described in
docs/live-runtime-cache-actor.md.
flowchart LR
A["AMM log"] --> B["Adapter decode"]
B --> C{"Event carries final values?"}
C -->|yes| D["StateUpdate directly into EvmCache"]
C -->|no| E["ResyncRequest for known slots"]
E --> F["evm-fork-cache: block trace first"]
F --> G["bulk storage / point fallback if unresolved"]
D --> H["post-block cached state"]
G --> H
Arbitrage examples
Two end-to-end examples show the canonical use case — pricing swaps across many pools offline to find and size a dislocation. Both warm real pools from an archive node, then quote entirely offline:
# Cross-DEX: quote USDC/WETH across Uniswap V2, V3, and Curve; find the best
# buy + sell venue and price the round trip.
E2E_RPC_URL=<archive-url> cargo
# Triangular: walk USDC -> USDT (Curve 3pool) -> WETH (Curve tricrypto2)
# -> USDC (Uniswap V3) and price the cycle.
E2E_RPC_URL=<archive-url> cargo
See examples/arbitrage_cross_dex.rs and
examples/arbitrage_triangular.rs.
One-shot V3 full-pool sync
The v3_sync module generates a ~360-byte EVM
program (tick spacing and storage layout baked in as immediates) that is
injected over a pool's code via an eth_call state override
(evm-fork-cache's bulk-storage transport) and walks the entire tick
bitmap inside the EVM — returning statics, every initialized tick's four
info words, and the whole observation ring in one call with zero calldata.
Live-measured on the USDC/WETH 0.05% pool: 1,563 ticks + 723 observations →
7,674 slots injected in ~140 ms for 26 CU (vs ~130k CU as per-slot point
reads — 7,674 × 17 CU), after
which a hard multi-tick-crossing quote runs in ~5 ms with zero lazy
fetches (vs ~2 s paging ticks over RPC on a windowed cache). A
calldata-driven partial variant refreshes selected bitmap-word ranges
(the planner-window shape, or dense spacing-1 pools in chunks).
E2E_RPC_URL=<https
# live parity suite (state, quote, and partial-window equivalence):
E2E_RPC_URL=<archive-url> cargo
One-shot flat-slot sync
The storage_sync module covers adapters whose
hot state is already known as a slot list:
- Uniswap V2: canonical
token0,token1, and packed reserves slots. - Solidly V2: config-supplied reserve/token slot layout.
- Balancer V2: vault balance slots discovered by cold-start or trace data.
- Curve: pool read-set slots discovered by cold-start or trace data.
Small fixed layouts use generated no-calldata bytecode with slots baked in.
Larger discovered read-sets use a reusable calldata-driven loader. Both execute
through the same eth_call code-override transport as V3 and inject the returned
storage words into EvmCache.
One-shot syncs (V3 and flat-slot) warm the cache only — pool status and
metadata still come from registry.cold_start, and the Balancer/Curve loaders
require a previously discovered read-set.
E2E_RPC_URL=<https
Live paid Alchemy RPC measurements on July 2, 2026, showed Balancer and Curve
refreshes dropping from discover→verify cold-starts around 330–360 ms to
one-shot storage programs around 75–76 ms once their read-set metadata is known.
The same run measured Uniswap V3 full-pool loading at 124.8 ms, while loading
7,670 slots. See
docs/benchmarks.md for the full table and caveats.
The progressive live-runtime roadmap, including lifecycle/version/backfill
semantics and the stage-by-stage implementation gates, is documented in
docs/live-runtime-master-plan.md. The
corresponding reproducible measurement contract and commands live in
docs/live-runtime-baselines.md. The public
identity, lifecycle, state-point, change-set, observer-event, and typed
compatibility-report contract is summarized in
docs/live-runtime-domain.md. Stage 2's
generation-scoped pool handlers, shared-emitter routing, state/job/resync
ownership indexes, isolation guarantees, and routing benchmarks are documented
in docs/live-runtime-ownership.md.
Stage 4's cache actor, complete-block envelope, subscriber transactions,
publication channels, and responsiveness gates are documented in
docs/live-runtime-cache-actor.md.
Stage 5's non-blocking cold-start worker, exact-hash prepared artifacts,
generation-safe cancellation, resumable round progress, priority scheduling,
independent pool publication, and Stage 6's background discovery/repair,
capacity-one handoffs, deferred warming, and dynamic adapter/factory ownership
are documented in
docs/live-runtime-progressive-cold-start.md.
Stage 9 adds AmmRegistrationArchive, a versioned chain-scoped sidecar for
built-in pool registrations and reusable Curve/Balancer read-set hints. Restored
queueable records are always Pending and must pass the current runtime's
hash-pinned cold-start verification before publication; generic EVM state stays
in evm-fork-cache. The deterministic, bounded file is written by same-directory
temporary file plus atomic rename. See
docs/warm-resume.md.
The archive's atomic rename protects that file alone. Applications that resume
ready pools from a persisted EvmCache must commit cache files, registration
archive, and hash-certified checkpoint as one application-level generation.
Publish a small manifest only after every generation file is flushed and synced;
otherwise a crash can pair a newer cache with an older valid checkpoint.
For event-time repair specifically, examples/trace_resync_latency.rs
compares a real Curve event through AmmSyncEngine with trace-only resync versus
forced storage-fetch fallback:
E2E_RPC_URL=<https
On the same paid Alchemy endpoint, a single Curve 3pool event measured 155.7 ms through trace-only resync versus 26.7 ms through direct storage fallback; the trace path is expected to amortize across many stale slots in the same block.
Performance
Once warmed, a quote is a fully-offline revm execution of the pool's own quote
entrypoint: ~8–9 µs for constant-product pools (Uniswap V2, Solidly) and
~40–85 µs for the heavier Curve / Balancer / Uniswap V3 quotes, on an M1 Pro.
Applying a Sync event is ~250 ns. That is eth_call-grade correctness
(it runs the real bytecode) at roughly 1000× lower latency than an RPC
eth_call, with no reimplemented math to drift. Full methodology, the
per-protocol table, and a comparison to other approaches are in
docs/benchmarks.md; reproduce with
E2E_RPC_URL=<archive-url> cargo bench --bench swap_sim.
Crate boundaries
This crate owns generic AMM state loading, event-driven synchronization, and
offline swap simulation. It deliberately does not contain transaction
signing/broadcasting, strategy scheduling, or multi-leg arbitrage routing (the
legacy routing layer was removed; it is rebuildable on top of simulate_swap —
the arbitrage examples above show exactly that).
Standard view interfaces are declared locally with alloy_sol_types::sol!, so
the crate builds from source with no generated bindings crate.
Examples
Start here — zero setup, no RPC: cargo run --example custom_adapter
(defines a novel AMM outside the crate, registers it, quotes both directions).
Everything else is env-gated and prints a skip message when unset:
| Example | Shows | Needs |
|---|---|---|
custom_adapter |
third-party adapter, register → quote | — |
adapter_pipeline |
register → cold-start → WS react → quote | ETH_WS_URL or E2E_RPC_URL |
factory_discovery_live |
discovery → cold-start → reactive | E2E_RPC_URL |
declarative_discovery |
token-basket PoolQuery → cold_start_many |
E2E_RPC_URL |
token_basket_bench |
batched vs per-pair discovery timing | E2E_RPC_URL |
v3_full_sync |
one-shot full-pool V3 sync + quote parity | E2E_RPC_URL |
verified_bytecode_seed |
seeding + on-chain code-hash verification | E2E_RPC_URL |
sync_latency |
prior vs one-shot sync latency per protocol | E2E_RPC_URL (public fallback) |
curve_cold_start_phases |
Curve discovery vs verify-only vs bundled | E2E_RPC_URL (public fallback) |
trace_resync_latency |
event-time trace resync vs storage fallback | E2E_RPC_URL |
arbitrage_cross_dex |
offline cross-DEX round-trip pricing | E2E_RPC_URL (archive) |
arbitrage_triangular |
offline triangular cycle pricing | E2E_RPC_URL (archive) |
Testing
These run from a clone of the repository. The published crate excludes the
integration test suite (tests/) to stay lean, so cargo test on a crates.io
download exercises only the inline unit tests — clone the repo for the full
suite.
Network-dependent tests are env-gated and #[ignore]d. With an archive RPC they
pin a block, cold-start a real pool, and assert simulate_swap equals the
on-chain quote at the same block (eth_call), plus a live WebSocket soak that
keeps state in sync from events only:
# RPC parity (mainnet pools; Base for Solidly via host-swap):
E2E_RPC_URL=<archive-url> cargo
# Live WS soak (Uniswap V2, and Curve across all dialects):
E2E_RPC_URL=<archive-url> cargo
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.