Graph-based route and cycle search over evm-amm-state
registries.
The crate is designed for warmed-cache routing where the first actionable quote can be streamed in microseconds to low milliseconds, while the exhaustive remainder continues in the background and can still replace the incumbent if it finds a better route.
evm-amm-state owns protocol adapters, discovery, cold-start, cache
synchronization, and exact pool quoting. This crate adds the search layer:
- Use
evm-amm-stateto discover or manually register pools. - Cold-start those pools into one shared
EvmCache. - Register the ready pools in
AdapterRegistry. - Build an
AmmGraphfrom the registry. - Use
AmmSearcherto find best A-to-B routes or bounded cycles.
Token addresses are graph nodes and directed AMM swap opportunities are edges. Multi-token pools are fully connected with directed edges between every distinct token pair, and parallel pools on the same pair remain separate edges.
An experimental deployment package is available in the repository's
sidecar directory.
It keeps one chain's graph and canonical WebSocket connection warm behind a
bounded REST API, including quote-time discovery and explicit token prewarming.
It is deliberately excluded from the library's public API and crates.io package;
see the sidecar README for its supported boundary and release status.
An initial, separately documented
ExperimentalExecutorRouter
contract demonstrates
exact-input execution for every currently routable protocol family, atomic
ERC-2612/Permit2 authorization, native-input wrapping, final-output protection,
and reusable CREATE2 deployment. It is unaudited, unstable, and not intended
for production or meaningful-value use. The sidecar exposes an explicitly
disabled-by-default executable-quote endpoint that performs snapshot-bound
quote-to-calldata translation, canonical source-block verification, stale-state
rejection, gas estimation, structured approval generation, protocol gating, and
the second-best-executable-route default minimum. It never signs or submits
transactions.
use alloy_primitives::{address, U256};
use evm_amm_search::{AmmGraph, AmmSearcher, GraphBuildOptions, RouteRequest};
use evm_amm_state::adapters::{AdapterRegistry, SimConfig};
# use evm_amm_state::adapters::AdapterCache;
#
# fn example(registry: &AdapterRegistry, cache: &mut dyn AdapterCache) -> Result<(), Box<dyn std::error::Error>> {
let report = AmmGraph::from_registry(registry, GraphBuildOptions::default());
let searcher = AmmSearcher::new(registry, &report.graph);
let usdc = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
let weth = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
let request = RouteRequest::new(usdc, weth, U256::from(1_000_000_u64))
.with_sim_config(SimConfig::default());
let best = searcher.find_best_route(&request, cache)?;
println!("{} hops, output={}", best.path.len(), best.amount_out);
# Ok(())
# }For production-style fan-out over a concrete EvmCache, use the overlay-backed
parallel API. It creates isolated EvmOverlay instances for worker threads, so
candidate quotes do not mutate the shared warmed cache:
use evm_amm_search::{ParallelSearchConfig, RouteRequest};
# use evm_amm_search::AmmSearcher;
# use evm_fork_cache::cache::EvmCache;
# use alloy_primitives::U256;
#
# fn example(searcher: &AmmSearcher<'_>, cache: &mut EvmCache) -> Result<(), Box<dyn std::error::Error>> {
# let usdc = alloy_primitives::Address::ZERO;
# let weth = alloy_primitives::Address::repeat_byte(1);
let request = RouteRequest::new(usdc, weth, U256::from(1_000_000_u64));
let routes = searcher.find_routes_parallel(
&request,
cache,
ParallelSearchConfig::default().with_workers(8),
)?;
# let _ = routes;
# Ok(())
# }When you have many independent route requests, prefer
find_routes_batch_parallel so worker threads and overlays are created once for
the whole batch rather than once per request.
For latency-sensitive clients, use stream_routes_parallel. Streaming always
starts with heuristic-first evaluation and emits best updates as soon as viable
quotes are found. The caller chooses whether to stop after the heuristic phase
or continue with a deduplicated exhaustive remainder:
use alloy_primitives::U256;
use evm_amm_search::{
RouteRequest, RouteSearchEvent, SearchControl, StreamingSearchConfig,
};
# use evm_amm_search::AmmSearcher;
# use evm_fork_cache::cache::EvmCache;
#
# fn example(searcher: &AmmSearcher<'_>, cache: &mut EvmCache) -> Result<(), Box<dyn std::error::Error>> {
# let token_in = alloy_primitives::Address::ZERO;
# let token_out = alloy_primitives::Address::repeat_byte(1);
let request = RouteRequest::new(token_in, token_out, U256::from(1_000_000_u64));
let report = searcher.stream_routes_parallel(
&request,
cache,
StreamingSearchConfig::default().with_top_k(3),
|event| {
if let RouteSearchEvent::BestUpdated { quote, phase, .. } = event {
println!("new best from {phase:?}: {}", quote.amount_out);
}
SearchControl::Continue
},
)?;
if report.heuristic_was_final_best == Some(false) {
println!("exhaustive search improved on the heuristic result");
}
# let _ = report;
# Ok(())
# }StreamingSearchConfig::heuristic_only() stops once the heuristic list is
exhausted. The default continues to exhaustive completion and reuses the same
per-hop quote cache, so heuristic paths are not simulated again during the
exhaustive remainder.
Streaming also emits RouteSearchEvent::Progress snapshots with candidate
counts, failed candidates, duplicate skips, known exhaustive fraction, current
best amount, and a heuristic confidence_bps score. The score is scoped to the
configured search universe and is a UX signal, not a statistical guarantee.
Use a stop policy when the caller wants the search itself to terminate once a threshold is reached:
use alloy_primitives::U256;
use evm_amm_search::{
RouteRequest, SearchControl, SearchFinality, StreamingSearchConfig,
};
# use evm_amm_search::AmmSearcher;
# use evm_fork_cache::cache::EvmCache;
#
# fn example(searcher: &AmmSearcher<'_>, cache: &mut EvmCache) -> Result<(), Box<dyn std::error::Error>> {
# let token_in = alloy_primitives::Address::ZERO;
# let token_out = alloy_primitives::Address::repeat_byte(1);
let request = RouteRequest::new(token_in, token_out, U256::from(1_000_000_u64));
let report = searcher.stream_routes_parallel(
&request,
cache,
StreamingSearchConfig::default().stop_at_confidence_bps(9_000),
|_| SearchControl::Continue,
)?;
assert_eq!(report.finality, SearchFinality::StopPolicySatisfied);
# Ok(())
# }Use an initial result gate when the caller wants to suppress quote-bearing events until a threshold is reached, then keep searching exhaustively and stream later improvements:
use alloy_primitives::U256;
use evm_amm_search::{
RouteRequest, RouteSearchEvent, SearchControl, StreamingSearchConfig,
};
# use evm_amm_search::AmmSearcher;
# use evm_fork_cache::cache::EvmCache;
#
# fn example(searcher: &AmmSearcher<'_>, cache: &mut EvmCache) -> Result<(), Box<dyn std::error::Error>> {
# let token_in = alloy_primitives::Address::ZERO;
# let token_out = alloy_primitives::Address::repeat_byte(1);
let request = RouteRequest::new(token_in, token_out, U256::from(1_000_000_u64));
let report = searcher.stream_routes_parallel(
&request,
cache,
StreamingSearchConfig::default().emit_initial_results_at_confidence_bps(9_000),
|event| {
match event {
RouteSearchEvent::Progress { progress } => {
println!("confidence={}bps", progress.confidence_bps);
}
RouteSearchEvent::InitialResultsReady { best, .. } => {
println!("first gated quote: {}", best.amount_out);
}
RouteSearchEvent::BestUpdated { quote, .. } => {
println!("later improvement: {}", quote.amount_out);
}
_ => {}
}
SearchControl::Continue
},
)?;
assert!(report.initial_results_released);
# Ok(())
# }For transport-level cold-start profiling, provider payload ceilings, and the
current HTTP/WebSocket call breakdown, see
docs/network-cold-start-benchmark.md.
The deterministic graph lifecycle baseline requires no provider:
cargo bench --bench graph_lifecycle
It compares full construction/rebuild and the current add/remove paths at 16, 64, and 320 pools. The live benchmark below covers cold start and route search.
The focused README benchmark is intentionally small and repeatable. It discovers
Uniswap V2/V3 pools for eight mainnet tokens (WETH, USDC, USDT, DAI,
WBTC, LINK, UNI, AAVE), cold-starts every ready pool with
ColdStartPolicy::Eager, builds a 234-edge graph, bulk-refreshes the optional
liquidity index, then runs 30 warm-cache streaming searches for:
10 WETH -> USDC
100 LINK -> AAVE
1000 DAI -> UNI
Run it locally with:
E2E_RPC_URL=<mainnet-url> cargo run --release --example docs_route_benchmark
Useful knobs:
DOCS_BENCH_RUNS=30
DOCS_BENCH_BLOCK=<pinned-mainnet-block>
DOCS_BENCH_MAX_HOPS=3
DOCS_BENCH_WORKERS=0 # 0 means available parallelism
DOCS_BENCH_PERSIST_CACHE=1
DOCS_BENCH_CACHE_DIR=.cache/docs-route-benchmark
Latest focused run: July 9, 2026, pinned Ethereum block 25494413, paid RPC
through LoadBalancedTransport + batching + gzip, release build, 10 workers.
The graph had 117 indexed pools, 8 token nodes, and 234 directed AMM
edges.
The hot path is intentionally split into graph work and simulation work. The
graph stores a StableDiGraph for stable node/edge identity and also maintains
a cached outgoing-edge view for route expansion. The cached view mirrors the
graph iterator order, so heuristic ordering and prefix-dominance behavior stay
stable while search avoids repeated node-token and edge-weight lookups.
Each heuristic request also keeps a request-local scheduler cache for token degree and connector-liquidity scores. That cache is discarded after the request, so it cannot go stale across graph updates, but it prevents repeated centrality scoring inside fast-lane connector selection and frontier ordering.
The memory tradeoff is one small cached record per directed graph edge plus a
short-lived per-request map for touched tokens/connectors. See
docs/search-performance-model.md for the
implementation notes, safety boundaries, and scheduler-only benchmark results.
Across both measured optimization rounds, the synthetic warm scheduler average
fell from 19.78ms to 4.63ms, a 76.6% reduction (4.27x faster).
| Step | First run |
|---|---|
| Cache build | 86.2ms |
| Factory discovery | 124.4ms |
cold_start_many without liquidity index |
1.479s |
| Graph build | 1.36ms |
| Liquidity refresh | 234 storage reads, 2.140s |
| Cold start plus liquidity refresh | 3.619s |
The benchmark flushes the warmed EvmCache to .cache/docs-route-benchmark.
On a pinned rerun at the same block, cache build was 235.4ms, factory
discovery was 131.4ms, cold_start_many was 1.192s, and the same liquidity
refresh dropped to 163.0ms. The persisted cache is useful for repeatable local
benchmarks, but live systems should still treat event ingestion as the source of
freshness.
The recommended balanced heuristic with a fresh liquidity index streams strong
quotes first and still runs the exhaustive remainder. first gap is the output
difference between the first streamed quote and the final exhaustive winner.
first-vs-best divergence counts runs where the first streamed quote was not
the final exhaustive winner.
| Route | Sim winner | First quote p50 / p95 / worst | Time to final winner p50 / p95 / worst | Heuristic done p50 / p95 / worst | Exhaustive done p50 / p95 / worst | First gap / first-vs-best divergence |
|---|---|---|---|---|---|---|
10 WETH -> USDC |
on | 0.284ms / 0.323ms / 0.339ms |
0.284ms / 0.323ms / 0.339ms |
4.09ms / 4.27ms / 4.38ms |
96.5ms / 99.8ms / 116.6ms |
0 bps; first quote was the final winner in 30/30 runs |
100 LINK -> AAVE |
on | 0.387ms / 0.461ms / 0.471ms |
1.59ms / 1.89ms / 3.07ms |
8.92ms / 11.1ms / 13.2ms |
242.1ms / 364.0ms / 629.3ms |
74.4 bps; first-vs-best divergence in 30/30 runs |
1000 DAI -> UNI |
on | 0.349ms / 0.419ms / 0.780ms |
7.61ms / 9.79ms / 13.1ms |
7.75ms / 9.93ms / 13.2ms |
485.2ms / 742.6ms / 939.0ms |
224.6 bps; first-vs-best divergence in 30/30 runs |
10 WETH -> USDC |
off | 0.285ms / 0.329ms / 0.541ms |
0.285ms / 0.329ms / 0.541ms |
4.17ms / 4.80ms / 6.32ms |
105.2ms / 147.9ms / 207.2ms |
0 bps; first quote was the final winner in 30/30 runs |
100 LINK -> AAVE |
off | 0.394ms / 0.487ms / 0.511ms |
1.68ms / 1.86ms / 1.94ms |
9.22ms / 11.1ms / 13.0ms |
262.6ms / 408.0ms / 726.5ms |
74.4 bps; first-vs-best divergence in 30/30 runs |
1000 DAI -> UNI |
off | 0.333ms / 0.394ms / 0.690ms |
7.71ms / 8.47ms / 10.0ms |
7.84ms / 8.60ms / 10.2ms |
477.8ms / 553.6ms / 569.5ms |
224.6 bps; first-vs-best divergence in 30/30 runs |
Without the liquidity index, the same balanced heuristic still found the same final winners in this run, but surfaced weaker early quotes for the multi-hop routes:
| Route | Time to final winner without liquidity | Time to final winner with liquidity | First gap without liquidity | First gap with liquidity |
|---|---|---|---|---|
10 WETH -> USDC |
24.4ms p50 |
0.284ms p50 |
8.8 bps; first-vs-best divergence in 30/30 runs |
0 bps; first quote was the final winner in 30/30 runs |
100 LINK -> AAVE |
62.7ms p50 |
1.59ms p50 |
9980.8 bps; first-vs-best divergence in 30/30 runs |
74.4 bps; first-vs-best divergence in 30/30 runs |
1000 DAI -> UNI |
102.7ms p50 |
7.61ms p50 |
9868.7 bps; first-vs-best divergence in 30/30 runs |
224.6 bps; first-vs-best divergence in 30/30 runs |
No heuristic/exhaustive divergence was observed for these three focused routes in the balanced configuration. Adaptive shortlist remains opt-in because broader production-basket audits have shown that shortlist bounds can change winners.
The comparison benchmark now focuses on LI.FI and 1inch. It cold-starts the same Uniswap V2/V3 basket, warms the local cache and liquidity index, starts local streaming search, and requests external quotes against current chain state. See docs/quote-quality-benchmarks.md for the full methodology, multi-run notes, and gas details.
Positive provider difference means the external provider quoted more output than local search; negative means local search quoted more output. The gross LI.FI and 1inch columns come from a three-run provider sample where both providers returned quotes. The local router gas and LI.FI net-gas column come from the final gas-simulated run.
| Route | Local best quote sample | Local best latency | Local router gas | LI.FI p50 gross diff / latency / gas | LI.FI net diff incl. gas | 1inch p50 gross diff / latency / gas |
|---|---|---|---|---|---|---|
10 WETH -> USDC |
17,451.115307 USDC |
381.5µs |
139,308 |
-21.1002 bps / 874.8ms / 823,081 |
+18.3966 bps |
-27.4128 bps / 253.6ms / 782,148 |
100 LINK -> AAVE |
8.721006564750064259 AAVE |
1.77ms |
218,872 |
-23.0615 bps / 765.5ms / 686,113 |
-24.6706 bps |
-27.1551 bps / 183.6ms / 453,075 |
1000 DAI -> UNI |
297.574325332547588103 UNI |
8.99ms |
219,966 |
-18.6252 bps / 665.4ms / 934,730 |
-22.9711 bps |
-25.4327 bps / 239.2ms / 954,862 |
The publishable result is that local warm search returns in sub-millisecond to
single-digit millisecond time. In the three-run provider sample, local search
beat both LI.FI and 1inch on all three routes on gross output. In the final
gas-simulated LI.FI comparison, LI.FI beat the local route on WETH -> USDC,
while local search beat LI.FI on LINK -> AAVE and DAI -> UNI.
The same run also benchmarked RouteSearchSession::refresh_affected after a
best-route pool was marked affected. This measures the search-session refresh
path after a caller has routed a Swap log and applied the corresponding cache
updates; it does not include WebSocket latency or AmmSyncEngine::ingest_batch
time.
| Metric | Result |
|---|---|
| Refreshes | 90 |
| Divergences vs full heuristic recompute | 0 |
| Affected materialized routes re-quoted | 2,760 total |
| Parallel probe routes quoted | 9,780 total |
| New quote executions | 90 total |
| Session start p50 / p95 / worst | 280.0ms / 694.3ms / 923.0ms |
| Incremental refresh p50 / p95 / worst | 4.37ms / 6.29ms / 7.58ms |
| Full heuristic recompute p50 / p95 / worst | 8.02ms / 11.85ms / 34.39ms |
The refresh path is exact within the session's materialized route set and probes
same-token-pair replacements around affected hops. When a topology or request
change falls outside that scope, the session returns RecomputeRequired.
The default route search mode is still exhaustive. When callers opt into
SearchMode::Heuristic(HeuristicSearchConfig::balanced()), the balanced preset
uses ordering and conservative pruning by default, while leaving adaptive edge
shortlisting off.
flowchart LR
Request["Route request A -> B"] --> FastDirect["Fast lane: best direct A -> B edge"]
FastDirect --> FastConnectors["Fast lane: A -> central connector -> B"]
FastConnectors --> Frontier["Heuristic frontier expansion"]
Frontier --> TargetFirst["Target-closing groups first"]
Frontier --> LiquidityRank["Liquidity-ranked branch groups when index exists"]
TargetFirst --> Prefix["Prefix dominance"]
LiquidityRank --> Prefix
Prefix --> Parallel["Parallel-edge ordering and optional balance pruning"]
Parallel --> Stream["Stream best updates"]
Stream --> Exhaustive["Deduplicated exhaustive remainder"]
| Heuristic | Balanced default | Requires liquidity index | What it does |
|---|---|---|---|
| Fast lane | on | optional | Quotes the best direct edge, then best two-hop routes through ranked central connectors before broad search. |
| Target-first group ordering | on | no | When a prefix can close to the target, that token group is evaluated before intermediate groups. |
| Prefix dominance | on | no | Drops a lower-output prefix that reaches the same token with a superset of visited tokens and used pools. |
| Protocol-aware edge ordering | on | optional | Within the same liquidity bucket, ranks V3-family before Curve, Balancer, then V2/Solidly/custom. |
| Liquidity-ranked branch expansion | off unless LiquidityPruningConfig::enabled() |
yes | Ranks non-target next-token groups by current-token pool balance, using max balance then sum balance. |
| Balance-aware parallel edge pruning | off unless LiquidityPruningConfig::enabled() |
yes | For same-prefix parallel edges, quotes deeper known-balance venues first and skips candidates whose output balance cannot beat the incumbent for that group. |
| Conservative upper-bound pruning | on, effective only with fresh target-token balances | yes | After an incumbent exists, skips prefixes whose reachable final-hop target-token balance cap cannot beat it. Unknown balances fail open. |
| Adaptive edge shortlist | off in balanced, on in latency_first() |
optional | Quotes only the top-ranked parallel edge first and defers/refines the rest. This is faster but approximate under bounded search. |
| Finalist re-simulation | on | no | Re-quotes retained finalists before returning to guard against heuristic ordering artifacts and shared cache effects. |
Example search order for a LINK -> AAVE request with fresh liquidity:
flowchart LR
LINK -->|"1. best direct edge if any"| AAVE
LINK -->|"2. deep LINK/WETH edge"| WETH -->|"then WETH/AAVE"| AAVE
LINK -->|"3. deep LINK/USDC edge"| USDC -->|"then USDC/AAVE"| AAVE
LINK -->|"4. broader frontier/refinement"| DAI --> AAVE
LINK -->|"5. exhaustive remainder"| UNI --> AAVE
Workers split the remaining materialized route paths after the heuristic phase.
Each worker gets its own EvmOverlay over the same warmed snapshot and shares a
quote cache, so an already-evaluated hop/path is not simulated again during the
exhaustive remainder.
Prefix dominance is deliberately conservative:
flowchart LR
A -->|"100 out"| E
A --> D -->|"95 out"| E
E --> B
The A -> D -> E prefix can be pruned only if the direct A -> E prefix has at
least as much output and no stricter visited-token or used-pool constraints. If
the longer prefix outputs more, or preserves a pool/token needed later, it stays.
For event-driven clients, start_route_session keeps the materialized route
set, best quote, quote cache, and a local parallel-edge probe index alive across
AMM updates. After the caller applies logs through AmmSyncEngine::ingest_batch,
pass the same logs or pool keys to the session and it will re-quote only the
materialized routes and local replacement probes that touch affected pools:
use alloy_primitives::U256;
use evm_amm_search::{
AffectedPools, RouteRequest, RouteUpdateEvent, SearchControl, StreamingSearchConfig,
};
# use evm_amm_search::AmmSearcher;
# use evm_amm_state::adapters::PoolKey;
# use evm_fork_cache::cache::EvmCache;
#
# fn example(
# searcher: &AmmSearcher<'_>,
# cache: &mut EvmCache,
# affected_pool: PoolKey,
# ) -> Result<(), Box<dyn std::error::Error>> {
# let token_in = alloy_primitives::Address::ZERO;
# let token_out = alloy_primitives::Address::repeat_byte(1);
let request = RouteRequest::new(token_in, token_out, U256::from(1_000_000_u64));
let mut session = searcher.start_route_session(
&request,
cache,
StreamingSearchConfig::default(),
|_| SearchControl::Continue,
)?;
let report = session.refresh_affected(
searcher,
cache,
AffectedPools::from_pool_keys([affected_pool]),
|event| {
if let RouteUpdateEvent::BestChanged { best, .. } = event {
println!("new incremental best: {:?}", best.map(|quote| quote.amount_out));
}
SearchControl::Continue
},
);
# let _ = report;
# Ok(())
# }The update path is exact within the session's materialized candidates. Heuristic
sessions also probe same-token-pair parallel replacements around materialized
hops, so an affected venue that was not in the original heuristic winner set can
still displace the best route. Conservative cases such as removed logs, graph
topology changes, degraded/unknown affected pools, or probe-budget overflow
return RecomputeRequired; callers should then start a fresh session.
With the live-runtime feature, LiveAmmGraph consumes the reliable
AmmStateCommit stream directly. Each contiguous commit produces a GraphDelta
and updates only the changed pool edges plus liquidity targets whose parallel
pair membership changed. State-only commits advance the AMM snapshot while
leaving GraphVersion stable; topology or active-generation changes advance it
exactly once. A skipped commit is rejected without partially mutating the live
graph, and recovery can reconcile from a newer immutable snapshot.
Construct live searchers with AmmSearcher::from_snapshot(snapshot, &live_graph).
The constructor rejects a graph from another runtime or AMM state version/point,
and all live quote paths build isolated overlays from the snapshot's immutable
cache rather than trusting the compatibility cache argument. Their per-hop quote
keys include the active PoolInstanceId, PoolStateRevision, and complete
AmmStatePoint, so a generation, revision, block hash, or state-position change
cannot reuse a stale quote. Persistent route sessions conservatively require a
recompute when either the graph version or state point changes. See
docs/incremental-search-universe.md for
the lifecycle contract, recovery rules, and Stage 7 benchmark.
With live-runtime, LiveRouteRuntime is the single correctness-critical AMM
change consumer and multiplexes logical route subscriptions. Each subscription
exposes a recoverable latest-value snapshot plus typed observer events:
use alloy_primitives::U256;
use evm_amm_search::{
GraphBuildOptions, LiveRouteRuntime, LiveRouteRuntimeConfig,
LiveRouteRuntimeEventKind, RouteRequest, RouteSubscriptionSpec,
StreamingSearchConfig,
};
# use evm_amm_state::adapters::AmmRuntimeHandle;
# async fn example(amm: &AmmRuntimeHandle) -> Result<(), Box<dyn std::error::Error>> {
# let token_in = alloy_primitives::Address::ZERO;
# let token_out = alloy_primitives::Address::repeat_byte(1);
let routes = LiveRouteRuntime::spawn(
amm,
GraphBuildOptions::default(),
LiveRouteRuntimeConfig::default(),
).await?;
let mut route = routes.subscribe(RouteSubscriptionSpec::new(
RouteRequest::new(token_in, token_out, U256::from(1_000_000_u64)),
StreamingSearchConfig::default(),
)).await?;
while let Ok(event) = route.next_event().await {
match event.kind() {
LiveRouteRuntimeEventKind::AmmCommitApplied { .. } => {
// Canonical state transition and graph refresh are complete.
}
LiveRouteRuntimeEventKind::RoutePublished { current, .. } => {
println!("current route: {:?}", current.as_ref().map(|quote| quote.quote()));
}
_ => {}
}
}
# Ok(())
# }The actor never waits for quote work or observers while draining AMM commits.
Jobs run on a bounded persistent worker pool, share immutable LiveSearchView
inputs, coalesce to the newest source while busy, and are externally
cancellable. Every result is fenced by runtime, state, complete point, graph,
subscription, and job identity. See
docs/live-route-runtime.md.
Every recoverable subscription snapshot includes the exact immutable
LiveSearchView used for its graph, registry, liquidity, and quote state.
Consumers can also replace a request in place; the subscription identity stays
stable while the epoch fence rejects late results from the previous request.
Calling a subscription's cancellation token immediately schedules actor cleanup;
cancel() additionally waits for the terminal watch transition. Dropping every
route handle shuts down the actor and releases the AMM runtime's critical change
subscription, while terminal graph/state failures remain recoverable through the
authoritative subscription watch.
The interactive amm-route-tui now uses this live pipeline: it renders and
accepts input while the runtime connects and hydrates pools, streams each usable
route as soon as its dependencies are ready, queues dynamic discovery through
the AMM worker, and saves chain-scoped registration/read-set hints for warm
resume. See docs/live-consumer-migration.md
and the providerless progressive_live_routes / dynamic_live_routes examples.
Exhaustive search is the default and enumerates every simple path inside the
configured hop/candidate bounds. Heuristic search is opt-in. Its default
[HeuristicSearchConfig::balanced] preset keeps the safer ordering heuristics
enabled: target-closing branches first, prefix dominance, fast-lane
direct/connector routes, protocol-aware parallel-edge ordering, finalist
re-simulation, and conservative balance-cap upper-bound pruning when a fresh
liquidity index is attached. It does not enable adaptive edge shortlisting
by default because benchmark audits showed that shortlist truncation can change
the winner under bounded heuristic search.
use alloy_primitives::U256;
use evm_amm_search::{HeuristicSearchConfig, RouteRequest, SearchConfig, SearchMode};
#
# let token_in = alloy_primitives::Address::ZERO;
# let token_out = alloy_primitives::Address::repeat_byte(1);
let heuristic = HeuristicSearchConfig::balanced()
.with_auto_connectors(8, 6)
.with_parallel_edge_limit(1)
.with_finalist_simulation(true, 16);
let request = RouteRequest::new(token_in, token_out, U256::from(1_000_000_u64))
.with_config(
SearchConfig::default()
.with_hops(1, 3)
.with_mode(SearchMode::Heuristic(heuristic)),
);
# let _ = request;For lower latency, callers can opt into the
[HeuristicSearchConfig::latency_first] preset. This enables adaptive
parallel-edge shortlisting and refinement; it surfaces strong quotes faster, but
it is approximate under connector, beam, candidate, or shortlist bounds. Use it
with streaming search when an early actionable quote is useful and continue to
the exhaustive remainder when exactness matters.
use evm_amm_search::{AdaptiveEdgeShortlistConfig, HeuristicSearchConfig};
let latency_first = HeuristicSearchConfig::latency_first()
.with_edge_shortlist(
AdaptiveEdgeShortlistConfig::enabled()
.with_initial_edges_per_pair(1)
.with_refinement_edges_per_pair(3)
.with_protocol_ordering(true),
);
# let _ = latency_first;Heuristic mode is designed to reduce warm-cache simulation count for production fan-out. It is intentionally approximate when connector, beam, candidate, or shortlist bounds remove otherwise valid paths; use exhaustive mode when you need full path coverage.
Heuristic search can optionally use a PoolLiquidityIndex sidecar to order and
prune same-token parallel edge groups. The index tracks only pool-held output
token balances for directed graph pairs that actually have parallel AMM edges,
loads them with one batched storage-sync refresh, and exposes hooks for callers
to keep those balances fresh from ERC-20 transfer logs or AMM sync slot updates.
use evm_amm_search::{
AmmGraph, AmmSearcher, GraphBuildOptions, LiquidityPruningConfig, PoolLiquidityIndex,
RouteRequest, SearchConfig,
};
# use alloy_network::AnyNetwork;
# use alloy_provider::Provider;
# use alloy_primitives::U256;
# use evm_amm_state::adapters::AdapterRegistry;
# use evm_fork_cache::cache::EvmCache;
#
# async fn example<P: Provider<AnyNetwork>>(
# registry: &AdapterRegistry,
# cache: &mut EvmCache,
# provider: &P,
# ) -> Result<(), Box<dyn std::error::Error>> {
# let token_in = alloy_primitives::Address::ZERO;
# let token_out = alloy_primitives::Address::repeat_byte(1);
let report = AmmGraph::from_registry(registry, GraphBuildOptions::default());
let (mut liquidity, _build) = PoolLiquidityIndex::from_registry(registry, &report.graph);
let _refresh = liquidity.refresh_all(cache, provider).await;
let searcher = AmmSearcher::new(registry, &report.graph).with_liquidity_index(&liquidity);
let request = RouteRequest::new(token_in, token_out, U256::from(1_000_000_u64))
.with_config(
SearchConfig::default()
.heuristic()
.with_liquidity_pruning(LiquidityPruningConfig::enabled()),
);
let routes = searcher.find_routes_parallel(&request, cache, Default::default())?;
# let _ = routes;
# Ok(())
# }Pruning is conservative: it only runs in heuristic search, only when a same-prefix group has at least two parallel edges, and stale or unknown balances remain fail-open. Non-final hard pruning is limited to two-token pools; multi-token pools are ordered by balance but not pruned until the final hop.
The providerless examples are the quickest way to learn the incremental live
surface. Provider-backed examples accept E2E_RPC_URL; several also expose
workload-specific environment knobs documented in their module headers.
| Example | Network | Shows |
|---|---|---|
progressive_live_routes |
None | Pools becoming independently routable as progressive work completes. |
dynamic_live_routes |
None | Dynamic pool add/remove without rebuilding the AMM or route runtimes. |
arbitrage_search |
Mainnet RPC | Route and cycle search over discovered pools. |
docs_route_benchmark |
Mainnet RPC | Reproducible focused search and incremental-refresh benchmark. |
production_basket_search |
Mainnet RPC | Larger discovery basket, liquidity indexing, and heuristic comparisons. |
aggregator_quote_comparison |
Mainnet RPC + APIs | Local quote quality and latency against external aggregators. |
demo_router_route_check |
Mainnet RPC | Route execution and gas simulation through the bundled demo router. |
multihop_quote_contract |
Mainnet RPC | Generated multihop quote-contract calldata and execution. |
rpc_payload_ceiling |
Mainnet RPC | Provider-specific request-body and storage-slot ceiling probing. |
Common live commands:
E2E_RPC_URL=<mainnet-url> cargo run --release --example arbitrage_search
E2E_RPC_URL=<mainnet-url> cargo run --release --example docs_route_benchmark
E2E_RPC_URL=<mainnet-url> cargo run --release --example production_basket_search
Live terminal route demo:
ETH_WS_URL=<mainnet-wss-url> cargo run --release --bin amm-route-tui
Set AMM_ROUTE_TUI_BENCH=1 for the same headless progressive bootstrap. It
reports time-to-runtime-handles, background work progress, and time-to-first
usable streamed route, including the final snapshot-local route gas estimate.
Bootstrap admits the bounded startup discovery set,
waits for every accepted pool job and state update to settle at one stable
baseline, and only then attaches the canonical subscriber and publishes runtime
handles. This prevents a new block from making slower V3 hydration artifacts
stale while faster V2 pools survive, the failure mode that previously made the
TUI appear V2-only.
AMM_ROUTE_TUI_BENCH_BOOTSTRAP_TIMEOUT_SECS bounds readiness and
AMM_ROUTE_TUI_BENCH_ROUTE_TIMEOUT_SECS independently bounds the first-route
milestone; setting AMM_ROUTE_TUI_BENCH_IDLE_TIMEOUT_SECS also waits for and
reports background quiescence. The retired synchronous comparison path is
available only with AMM_ROUTE_TUI_LEGACY_BENCH=1.
The TUI also loads .env automatically via dotenvy, so this is enough:
cp .env.sample .env
# edit WS_RPC_URL or E2E_RPC_URL
cargo run --release --bin amm-route-tui
Set AMM_ROUTE_TUI_RPC_URLS to a comma-separated list of HTTPS RPC endpoints to
load-balance state traffic across multiple providers. The TUI keeps
WS_RPC_URL/ETH_WS_URL as the single canonical block/log subscription
connection, while pool discovery, cold-start, cache reads, liquidity refreshes,
and route simulations use LoadBalancedTransport wrapped in BatchingTransport
with gzip enabled. AMM_ROUTE_TUI_RPC_BATCH_SIZE defaults to 150.
The [network] section in .amm-route-tui.toml additionally configures
provider weights, per-endpoint request-byte and in-flight limits, cold-start
concurrency, bulk slot/byte ceilings, point-read fallback batching, and the
maximum address set per log subscription. Known Alchemy and QuickNode hosts use
benchmarked safe defaults; unknown endpoints use a conservative profile.
amm-route-tui opens the alternate-screen shell and input receiver before any
provider connection, cache load, discovery, or hydration. A background bootstrap
establishes a verified latest-block baseline, starts AmmRuntime, attaches its
bounded cold-start worker and canonical Alloy subscriber, installs factory
watchers, and returns a LiveRouteRuntime while pool work continues. The UI
renders typed phase progress throughout and can be quit safely during any phase.
Startup factory discovery runs before subscriber attachment and is bounded by
AMM_ROUTE_TUI_MAX_POOLS (default 128, 0 means uncapped). The cap covers
manual pools, restored registration hints, and newly discovered candidates;
configured pools and the selected token pair are retained first. Lifecycle
counts are collapsed to the newest generation of each logical pool, so a
retried stale-baseline attempt is not presented as another failed AMM and does
not consume the discovery budget. Each verified
pool becomes searchable independently while it warms, but the subscriber is
attached only after the bounded set is idle on the original baseline;
the three immutable built-in Curve pools carry provider-verified get_dy read
sets and have their exact-hash account/code proofs prewarmed before cache
handoff. They therefore use one-round background verification and remain
executable from immutable route snapshots instead of entering the unsupported
worker-side discover phase. User-configured Curve pools without a known read
set remain pending until their discovery metadata is supplied or restored from
a prior archive. Supply discovered_slots under [[curve_pools]] as decimal or
0x-prefixed values when onboarding one directly.
The initial input/output selection and every later picker selection also queue
uncapped, focused discovery between that token and the core connector basket.
After focused work becomes idle, the TUI uses the runtime's deferred service
class to fill in the remaining configured-token pairs one request at a time;
focused selections and repair work retain scheduler priority. Set
AMM_ROUTE_TUI_BACKGROUND_DISCOVERY=0 to disable this idle backfill.
After subscriber attachment, the live graph and active route subscriptions
update from reliable AMM commits
without rebuilding the registry, adapters, or route runtime. State-only commits
reuse graph topology, topology commits mutate it incrementally, and request
changes are generation-fenced so late subscription or discovery results cannot
overwrite newer UI state. The route panel preserves the last accepted quote
while its replacement is computing, then replaces or clears it when the new
request reaches a terminal state. USD annotations are fetched from CoinGecko's
simple/token_price
endpoint by default, using the listed token contract addresses; if the price
request fails or a token is missing from the response, the quote still renders
without that token's dollar value. Disable this with AMM_ROUTE_TUI_PRICES=0,
or set AMM_ROUTE_TUI_COINGECKO_API_KEY / COINGECKO_API_KEY for a CoinGecko
Demo key. For paid Pro keys, set AMM_ROUTE_TUI_COINGECKO_PRO_API_KEY or
COINGECKO_PRO_API_KEY; the TUI switches to the Pro API root and sends the
x-cg-pro-api-key header automatically. CoinGecko prices are fetched in
one-contract requests to tolerate the public endpoint's current per-request
address cap and rate limits. Each refresh prioritizes the selected input/output
tokens, then route intermediates currently shown in the main panel, then the
rest of the selector list. Keyless defaults fetch at most 10 token prices per
60-second refresh with a 2.1-second delay between requests; keyed defaults fetch
up to 60 prices per 60-second refresh with a 0.5-second delay between requests.
Tune AMM_ROUTE_TUI_PRICE_REFRESH_SECS,
AMM_ROUTE_TUI_COINGECKO_REQUESTS_PER_REFRESH, and
AMM_ROUTE_TUI_COINGECKO_REQUEST_DELAY_MS if you have a higher plan or want a
stricter budget. Live mode benchmarks route gas locally against the exact
immutable LiveSearchView that produced the quote. Bootstrap installs the demo
router, discovers the configured tokens' balance layouts, and warms their
transfer dependencies before publishing snapshots; no provider reads are mixed
into the later simulation. Multi-hop simulation seeds balances through those
known layouts and only preflights the route's initial router input; intermediate
tokens must be produced by the preceding hop. Disable this with
AMM_ROUTE_TUI_GAS_ESTIMATES=0 (AMM_ROUTE_TUI_GAS=0 remains accepted for
compatibility). With Tenderly credentials configured, t independently replays
the displayed versioned live route at the end of its exact block. The TUI uses
the block's transaction count as Tenderly's insertion index so a post-block
snapshot is not accidentally replayed from pre-transaction state. It verifies
the canonical block hash before and after the request, injects the demo-router
code and only its input-token balance, then requires Tenderly's returned
amountOut to match the quote within
AMM_ROUTE_TUI_TENDERLY_OUTPUT_TOLERANCE_BPS (1 basis point by default). Tokens
without a verified balance layout fail closed. Library users can call
simulate_versioned_route_gas with a VersionedRouteQuote and its exact
LiveSearchView; cross-view inputs are rejected before EVM execution.
DemoRouter remains demonstration-only and must not be used in production. The
header includes a
right-aligned two-row status group. The
chain row is green when block/log
ingestion is synced, yellow while logs are being applied or a block is advancing,
and red on WebSocket/cache sync errors; it shows details such as applying 1 log from block X and synced 1 log from block X. The graph row is green when all
loaded pools are searchable, yellow when pools are warming or a coherent graph
has degraded pools, and red only when the runtime is untrusted or shutting down;
its idle detail separates routing, discovered, degraded, and failed counts.
Route progress shows streamed candidate counts, confidence, and the exhaustive
fraction once the candidate universe is known. The route panel also shows pool
coverage for both selected tokens and a red UNSAFE ROUTE warning when priced
output loses at least 10% of priced input value. Override that threshold with
AMM_ROUTE_TUI_MAX_VALUE_LOSS_BPS (set 0 to warn on any loss). Use
Tab/arrow keys to move
between input token, output token, and amount without editing them. Pressing
Enter on an input/output token opens a searchable token picker; once the picker
is open, typing filters symbols and addresses, Up/Down moves the highlighted
result, and Enter selects it. If the search text is not already listed, Enter
treats it as either an address or a token symbol. Addresses fetch ERC-20
metadata directly; symbols are resolved through the token-list registry scoped
to the connected chain. Selecting an already-listed token follows the same
focused factory-discovery path as a newly resolved token, connecting it to the
other selected token and known core connectors. Pressing Enter on the amount field
starts amount editing; typing and Backspace only modify the amount while that
edit mode is active, and Enter/Esc leaves it. Outside token search typing, n
enters the explicit arbitrary-token lookup mode, r requotes, t reports that
live snapshot simulation is not yet wired into the UI, and q exits.
Arbitrary-token discovery selects the token immediately and queues each
compatible factory watcher; routes become available as matching pools complete
background verification. AMM_ROUTE_TUI_TOKEN_REGISTRY_URL defaults to
https://tokens.uniswap.org; set it to another token-list URL, or to off, to
control symbol lookup. Set
AMM_ROUTE_TUI_INPUT, AMM_ROUTE_TUI_OUTPUT, and
AMM_ROUTE_TUI_AMOUNT to choose the initial route. The
included router runtime can be validated against live mainnet state with
cargo run --release --example demo_router_route_check; the check warms a
deterministic pool set and proves generated Curve 3pool, Curve
tricryptoUSDC-ng, two-hop DAI -> WBTC, and Uniswap V3 routes execute through
DemoRouter with actual output matching the quote. The
default selector token list is hardcoded in src/bin/amm_route_tui.rs; provide
.amm-route-tui.toml or amm-route-tui.toml in the working directory, or set
AMM_ROUTE_TUI_CONFIG to a .toml file, to extend or replace that list. The
config supports tokens, manual pools for
Uniswap V2-style, Uniswap V3-style, PancakeSwap V3-style, and Curve pools, plus
the shorthand curve_pools table. See .amm-route-tui.toml.sample. The TUI
enables disk-backed EvmCache persistence by default under
.cache/amm-route-tui and stores a separate chain-scoped registration/read-set
archive on orderly shutdown. Cache, archive, and canonical block checkpoint are
sealed into an immutable generation; a synced manifest selects that generation
only after every file is durable and all discovery, cold-start, and state-update
work is quiescent. Quitting while background work remains abandons the staging
generation and preserves the previous complete manifest instead of publishing a
partial registration archive. On restart, that checkpoint is verified
against the canonical provider; ready pools are restored at the certified
point, live subscriptions are established, and intervening blocks are replayed
before the route runtime is exposed. Missing, corrupt, or reorged checkpoints
fall back to normal cold start without trusting persisted EVM state. Verified
checkpoints more than AMM_ROUTE_TUI_MAX_WARM_CATCHUP_BLOCKS behind head also
rebuild at the latest block instead of delaying readiness with an unbounded
exact replay; the default ceiling is 256 blocks. Both fallback paths retain
registration and read-set metadata only as cold-start hints. An
interrupted shutdown leaves the previous manifest-selected generation intact;
orphan staging/sealed generations are ignored. Treat the checksum-protected
archive as trusted local cache data, not an authenticated distribution format.
The layout, commit ordering, and failure matrix are documented in
docs/warm-checkpoint-consistency.md. Set
AMM_ROUTE_TUI_CACHE_DIR to move these files or
AMM_ROUTE_TUI_PERSIST_CACHE=0 to disable generic EVM cache persistence; the
small registration archive remains enabled. Set
AMM_ROUTE_TUI_STOP_CONFIDENCE_BPS,
AMM_ROUTE_TUI_STOP_EXHAUSTIVE_FRACTION_BPS,
AMM_ROUTE_TUI_INITIAL_RESULT_CONFIDENCE_BPS, and
AMM_ROUTE_TUI_INITIAL_RESULT_EXHAUSTIVE_FRACTION_BPS to try the streaming stop
and initial-result gate policies from the UI. The route
panel lays the selected swap path out left-to-right with a width-aware token
lane, per-leg amounts, pool labels, node edge counts, and alternate registered
venue paths beneath the selected path. When the terminal is wide enough, the
panel also prints full Etherscan token and pool URLs so terminals with native URL
detection can open them with Command-click. Block/log updates refresh quotes
against the current graph, while topology changes when discovery registers new
pools.
Interactive route quotes finish at the heuristic boundary by default so every
block produces a terminal result instead of cancelling an unfinished exhaustive
search. Set AMM_ROUTE_TUI_EXHAUSTIVE_SEARCH=1 to continue through the full
candidate universe. The route panel retains and displays the top quoted routes
and venue coverage (not only the winning path), with up to four parallel direct
and connector venues admitted per pair. Snapshot-only V3 quoting uses the real
pool/quoter math while neutralizing only the ERC-20 transfer that precedes the
quoter's intentional revert; Pancake V3 additionally warms its real pool runtime
and complete shifted storage layout. E2E_RPC_URL or RPC_URL can be used
instead of ETH_WS_URL;
https:// endpoints are converted to wss://.
production_basket_search embeds a mainnet basket from
@uniswap/default-token-list@22.4.0, discovers canonical Uniswap V2/V3 pools
through evm-amm-state::PoolDiscovery, cold-starts them, builds the AMM graph,
and reports discovery, cold-start, graph-build, and route-search timings.
Use PROD_BASKET_TOKEN_LIMIT, PROD_BASKET_MAX_POOLS,
PROD_BASKET_SEARCH_LIMIT, PROD_BASKET_SEARCH_WORKERS,
PROD_BASKET_MAX_HOPS, and PROD_BASKET_MAX_CANDIDATES to scale the run.
Set PROD_BASKET_PRIME_CACHE=1 to run a direct-cache priming search before
measured overlay passes, PROD_BASKET_WARM_SEARCH_RUNS to repeat the measured
warm search, and PROD_BASKET_SEARCH_MODE=heuristic to benchmark heuristic
routing. Set PROD_BASKET_LIQUIDITY_PRIMING=1 with heuristic mode to bulk-load
directed-edge input/output balances and enable balance-aware ordering/pruning.
PROD_BASKET_HEURISTIC_PRESET=balanced is the default; set
PROD_BASKET_HEURISTIC_PRESET=latency_first or PROD_BASKET_EDGE_SHORTLIST=1
to enable adaptive edge shortlisting.
Set PROD_BASKET_ORDERING_COMPARE=1 to print layered heuristic comparisons:
baseline ordering, target-first ordering, prefix dominance, fast lane,
shortlist/protocol ordering, upper-bound pruning, and full liquidity ordering.
Set PROD_BASKET_LIQUIDITY_COMPARE=1 to print an in-run baseline pass with
liquidity pruning disabled before the liquidity-pruned measured pass. Set
PROD_BASKET_INCREMENTAL_COMPARE=1 to start route sessions on the warmed graph,
synthetically mark one best-route pool affected, run refresh_affected, and
audit each result against a full recompute.
Latest live run: July 9, 2026, pinned Ethereum blocks 25494189
(max_hops=2) and 25494192 (max_hops=3), paid RPC through
LoadBalancedTransport + batching + gzip, release build. Both runs used
PROD_BASKET_PRIME_CACHE=1, PROD_BASKET_SEARCH_MODE=heuristic,
PROD_BASKET_LIQUIDITY_PRIMING=1, PROD_BASKET_ORDERING_COMPARE=1,
PROD_BASKET_MAX_CANDIDATES=64, and the latency-first effective policy
(PROD_BASKET_EDGE_SHORTLIST=1).
Workload:
tokens=48
pools_selected=320 # 160 Uniswap V2, 160 Uniswap V3
graph_nodes=47
graph_edges=640
queries=40
workers=10
max_hops=2 and max_hops=3
max_candidates=64
heuristic.parallel_edge_limit=1
Setup timings:
| Step | Result |
|---|---|
| Discovery | 360.9ms to 490.3ms |
cold_start_many |
1.326s to 1.607s |
| Graph build | 1.31ms to 1.74ms |
| Liquidity refresh | 640 storage reads, 11.18s to 11.23s |
Warm measured search timings:
| Max hops | Search time | Quote executions | Failed quotes | Viable routes |
|---|---|---|---|---|
| 2 | 35.6ms |
306 |
37 |
195 |
| 3 | 38.6ms |
621 |
56 |
321 |
Layered ordering comparison:
| Max hops | Variant | Elapsed | Median first quote | Median eventual best | Quote executions | Divergences |
|---|---|---|---|---|---|---|
| 2 | Baseline ordering | 1.986s |
1.53ms |
1.91ms |
783 |
0/40 |
| 2 | Target first + prefix | 915.1ms |
1.29ms |
1.43ms |
783 |
0/40 |
| 2 | Shortlist + protocol ordering | 898.4ms |
1.23ms |
1.49ms |
783 |
2/40 |
| 2 | Full liquidity ordering | 734.4ms |
1.24ms |
1.28ms |
783 |
3/40 |
| 3 | Baseline ordering | 3.020s |
1.69ms |
1.89ms |
3455 |
0/40 |
| 3 | Target first + prefix | 1.737s |
1.35ms |
1.54ms |
3285 |
0/40 |
| 3 | Shortlist + protocol ordering | 1.828s |
1.27ms |
4.03ms |
3056 |
2/40 |
| 3 | Full liquidity ordering | 1.199s |
1.32ms |
1.38ms |
2887 |
2/40 |
The full liquidity run pruned 58 edges and 57 prefixes at max_hops=2,
and 90 edges plus 60 prefixes at max_hops=3. The target-first and prefix
dominance layers preserved exhaustive agreement in these runs. The adaptive
shortlist/protocol and full-liquidity variants are faster but approximate:
they diverged from the exhaustive audit in 2 to 3 out of 40 queries.
V1 ranks routes by gross output. It does not include gas-aware ranking, split
routing, transaction encoding, HTTP serving, or pool bootstrap helpers. Discovery
and cold-start remain explicit evm-amm-state steps.
Fynd is a high-performance real-time DeFi routing engine built on Tycho and was a useful reference point for graph-based routing. It is well worth exploring alongside this crate.