diff --git a/Cargo.lock b/Cargo.lock index 13f26ef..4be44ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1909,8 +1909,7 @@ dependencies = [ [[package]] name = "evm-fork-cache" version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9027f8dad2f7a11e77e841f9f53594d56cc9d904c7dd8aa68d76a092decd159e" +source = "git+https://github.com/KaiCode2/evm-fork-cache?rev=0c9af358d90c87e6452d5b3f1e252822043269f2#0c9af358d90c87e6452d5b3f1e252822043269f2" dependencies = [ "alloy-consensus", "alloy-contract", diff --git a/Cargo.toml b/Cargo.toml index 813a5bd..3586fc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,10 +61,10 @@ curve = ["adapters"] # Curve StableSwap plain pools (get_dy) experimental-protocols = [] [dependencies] -# Companion crate. Uses the public 0.2.1 release for bulk storage extraction, -# custom storage programs, typed errors, reactive resync, and trace-based -# storage-slot discovery. -evm-fork-cache = "0.2.1" +# Companion crate. Temporarily pinned to the access-list read-set prewarm commit +# so this branch can use cache-owned `eth_createAccessList` warming without +# duplicating the primitive locally. +evm-fork-cache = { git = "https://github.com/KaiCode2/evm-fork-cache", rev = "0c9af358d90c87e6452d5b3f1e252822043269f2" } alloy-eips = "1.0.38" alloy-network = "1.0.38" @@ -183,6 +183,12 @@ required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2", "solidly-v2", "c name = "adapter_swap_sim_rpc" required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2", "solidly-v2", "curve"] +# Live parity for eth_createAccessList two-shot cold warming (env-gated, #[ignore]): +# `cold_start_primed`'s access-list-derived read-set equals local discovery's. +[[test]] +name = "access_list_discovery_rpc" +required-features = ["curve"] + # Offline revm execution of the generated V3 one-shot sync programs. [[test]] name = "v3_sync" diff --git a/README.md b/README.md index 966a3cf..4cd81b0 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,18 @@ discovery, a trace, or a registry), joining V2/V3 in the same bundled call. Comb the happy path is `find(PoolQuery::basket(..)) → cold_start_many → register`, with request count driven by bootstrap phases rather than pool count. +**Fast first boot (no prior read-set).** Even a layout-free pool's *very first* +cold start is fast by default: before falling back to local discovery (which runs +the `get_dy` / `getPoolTokens` view-call in local revm over a cold cache, faulting +each SLOAD serially over RPC), `cold_start_many` derives the read-set with a +single `eth_createAccessList` and bulk-loads it, so the discover call then runs +warm through the same provider and batch storage fetcher installed on the +`EvmCache`. `AdapterRegistry::cold_start_primed(pool, cache, policy)` is the +single-pool async entry point. This needs no configuration and no separate RPC +handle on the happy path; a provider that lacks `eth_createAccessList` (or any +per-pool failure) transparently falls back to local discovery. Opt out with +`AdapterRegistry::with_access_list_discovery(false)`. + ### Extending with a new AMM You can add a brand-new AMM from *outside* the crate — no fork, no `src/` edit — diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 4efa1d7..4e5cdbe 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -119,10 +119,24 @@ faulting in each slot it SLOADs. That first-discovery cost — not warmed quotin is what makes a cold Curve boot lag Uniswap V2/V3, whose hot state is a known slot set (or tick-bitmap program) hydrated in one bundled `eth_call`. -Once the read-set is known, the gap closes to the one-shot figures above (the -same Curve 3pool row: **~361 ms → ~75 ms**). Two paths reuse a persisted -`CurveMetadata.discovered_slots` (from a prior discovery, a block trace, or a -registry): +**Fast first boot (no prior read-set).** The discovery cost above is dominated by +*serial* per-slot faulting. `AdapterRegistry::cold_start_primed` (and +`cold_start_many`) instead derive the read-set with a single `eth_createAccessList` +and bulk-load it through the `EvmCache`, so the discover call then runs warm — no +serial faulting. +Measured on July 7, 2026, using the paid Alchemy mainnet endpoint from +`E2E_RPC_URL` in `.env` with the benchmark's gzip-enabled HTTP client +(`CURVE_PHASES_ITERS=5`, Tricrypto2, block `25_481_590`): local discovery +**717.8 ms → access-list first boot 483.6 ms (~1.5× faster)**. Known-read-set +paths stayed near the one-shot floor: verify-only `cold_start` **116.9 ms** and +`cold_start_many` **110.7 ms**. +This needs no prior read-set, no configuration, and no separate RPC handle; a +provider without `eth_createAccessList` transparently falls back to local +discovery. + +Once the read-set is known, the gap closes to the one-shot figures above. Two +paths reuse a persisted `CurveMetadata.discovered_slots` (from a prior discovery, +a block trace, or a registry): - **verify-only `cold_start`** — the planner skips discovery and warms exactly the known slots in a single verify round; @@ -130,11 +144,11 @@ registry): the identical fast path Uniswap V2/V3 take. [`examples/curve_cold_start_phases.rs`](../examples/curve_cold_start_phases.rs) -times all three (discovery vs verify-only vs `cold_start_many`) against a live -pool and prints the breakdown — run it for numbers on your own endpoint: +times all four (discovery, access-list first boot, verify-only, `cold_start_many`) +against a live pool and prints the breakdown — run it for numbers on your endpoint: ```bash -E2E_RPC_URL= cargo run --release --example curve_cold_start_phases +E2E_RPC_URL= CURVE_PHASES_ITERS=5 cargo run --release --example curve_cold_start_phases ``` The optional `CurveMetadata::with_code_seed` removes the one lazy code fetch a diff --git a/docs/curve-adapter.md b/docs/curve-adapter.md index 969382c..8dd6c43 100644 --- a/docs/curve-adapter.md +++ b/docs/curve-adapter.md @@ -83,6 +83,14 @@ Repairs mirror Balancer: a reverting/empty discover → re-run cold-start; an archive-miss on a discovered slot → `VerifySlots`; a per-slot `SlotFetch` distinguishes a genuine zero from a fetch failure. +The local discover faults each `get_dy` SLOAD serially over RPC — the dominant +cost of a first boot. `AdapterRegistry::cold_start_primed` (and `cold_start_many`) +accelerate it: they derive the read-set with **one `eth_createAccessList`**, +bulk-load it through the `EvmCache`, then run the discover **warm**. This needs +no prior read-set or separate RPC handle; a provider without `eth_createAccessList` +transparently falls back to the plain local discovery above. See +[`docs/benchmarks.md`](benchmarks.md#curve-cold-start-discovery-vs-a-known-read-set). + **Verify-only** — the read-set is already known (`discovered_slots` pre-populated from a prior discovery, a block trace, or a registry). The planner **skips discovery entirely** — no pool-account/bytecode fetch and no cold-cache `get_dy` diff --git a/examples/curve_cold_start_phases.rs b/examples/curve_cold_start_phases.rs index 8bb7f2b..ca6dd12 100644 --- a/examples/curve_cold_start_phases.rs +++ b/examples/curve_cold_start_phases.rs @@ -182,7 +182,12 @@ async fn main() -> Result<()> { "a known-read-set Curve pool must be one-shot eligible" ); let outcomes = curve_registry() - .cold_start_many(&mut pools, &mut cache, provider.as_ref(), ColdStartPolicy::Eager) + .cold_start_many( + &mut pools, + &mut cache, + provider.as_ref(), + ColdStartPolicy::Eager, + ) .await?; ensure_ready(&outcomes[0], "cold_start_many")?; Ok(()) @@ -195,13 +200,39 @@ async fn main() -> Result<()> { })? }; + // 4) access-list first boot: the read-set is UNKNOWN (empty), but + // `cold_start_primed` derives it via one `eth_createAccessList` + one + // bundled load, then runs the discover warm — no serial faulting. This is + // the fast path for a genuine first boot (no prior discovery needed). + let access_list = measure(iterations, || { + let provider = provider.clone(); + async move { + let mut cache = cache(provider.clone(), block).await; + let mut reg = curve_registration(Vec::new(), None); + let outcome = curve_registry() + .cold_start_primed(&mut reg, &mut cache, ColdStartPolicy::Eager) + .await?; + ensure_ready(&outcome, "cold_start_primed")?; + Ok(()) + } + }) + .await + .map(|durations| PhaseStats { + durations, + details: "eth_createAccessList + bundled load, then warm discover".to_string(), + })?; + print_row("discovery cold_start (cold first boot)", &discovery); + print_row("access-list first boot (cold_start_primed)", &access_list); print_row("verify-only cold_start (known read-set)", &verify_only); print_row("cold_start_many (known read-set)", &bundled); let base = discovery.median_ms(); println!( - "\nverify-only is {:.1}x faster than first-discovery; cold_start_many is {:.1}x faster.", + "\naccess-list first boot is {:.1}x faster than local discovery (both from an \ + unknown read-set); verify-only is {:.1}x and cold_start_many {:.1}x (both reuse a \ + known read-set).", + base / access_list.median_ms().max(f64::MIN_POSITIVE), base / verify_only.median_ms().max(f64::MIN_POSITIVE), base / bundled.median_ms().max(f64::MIN_POSITIVE), ); @@ -276,7 +307,10 @@ async fn discover_once(provider: SharedProvider, block: BlockId) -> Result, code_seed: Option) -> PoolRegistration { +fn curve_registration( + discovered_slots: Vec, + code_seed: Option, +) -> PoolRegistration { let mut metadata = CurveMetadata::default() .with_coins(vec![USDT, WBTC, WETH]) .with_discovered_slots(discovered_slots) diff --git a/src/adapters/balancer_v2.rs b/src/adapters/balancer_v2.rs index 88d9309..8fc26ae 100644 --- a/src/adapters/balancer_v2.rs +++ b/src/adapters/balancer_v2.rs @@ -87,7 +87,11 @@ impl AmmAdapter for BalancerV2Adapter { }; Ok(Box::new(BalancerV2ColdStartPlanner::new( - vault, pool_id, known_slots, tokens, policy, + vault, + pool_id, + known_slots, + tokens, + policy, ))) } diff --git a/src/adapters/cold_start.rs b/src/adapters/cold_start.rs index f53ec1c..e03100f 100644 --- a/src/adapters/cold_start.rs +++ b/src/adapters/cold_start.rs @@ -22,6 +22,7 @@ //! on-chain zero and a transient archive miss become *distinguishable* repairs. use alloy_primitives::{Address, B256, Bytes, U256}; +use evm_fork_cache::AccessListCall; use evm_fork_cache::CacheError as UpstreamCacheError; use evm_fork_cache::bulk_storage::{StorageProgram, run_storage_programs}; use evm_fork_cache::cache::{CodeSeedState, EvmCache}; @@ -879,7 +880,19 @@ impl AdapterRegistry { } } - // Step 4: finalize every fallback pool through the normal cold-start. + // Step 3.5: prime any layout-free fallback pool (Curve/Balancer with no + // known read-set) via the cache's `eth_createAccessList` read-set + // fetcher + batch prewarm, so the step-4 cold-start below runs warm + // instead of faulting each slot serially over RPC. Best-effort: an + // un-primable pool (provider lacks `eth_createAccessList`, the call + // reverted, or it touched no slots) keeps the correct local-discovery + // fallback intact. + if self.access_list_discovery { + self.prime_fallback_read_sets(pools, &is_fallback, cache); + } + + // Step 4: finalize every fallback pool through the normal cold-start + // (now warm for any pool primed in step 3.5). for (index, pool) in pools.iter_mut().enumerate() { if is_fallback[index] { outcomes[index] = Some(self.cold_start(pool, cache, policy)?); @@ -892,6 +905,102 @@ impl AdapterRegistry { .map(|outcome| outcome.expect("every pool is fast-hydrated or fell back")) .collect()) } + + /// Cold-start one pool, deriving an unknown read-set via `eth_createAccessList` + /// first (the two-shot first-boot fast path). + /// + /// The single-pool, cache-backed analog of [`cold_start`](Self::cold_start): + /// where `cold_start` runs the discover view-call in local revm over a cold + /// cache — faulting each SLOAD one-at-a-time over RPC — this asks the node for + /// the discover call's access list through the [`EvmCache`]'s own provider, + /// prewarms those slots through the cache's batch storage fetcher, then + /// finalizes through the normal cold-start (now warm). A layout-free pool + /// (Curve / Balancer) whose read-set is already known, or any + /// named/derived-slot protocol (Uniswap V2/V3, Solidly), simply takes the + /// normal path. The same graceful fallback and `Err` propagation apply. + pub async fn cold_start_primed( + &self, + pool: &mut PoolRegistration, + cache: &mut EvmCache, + policy: ColdStartPolicy, + ) -> Result { + if self.access_list_discovery { + let fallback = [true]; + self.prime_fallback_read_sets(std::slice::from_ref(&*pool), &fallback, cache); + } + self.cold_start(pool, cache, policy) + } + + /// Two-shot read-set priming for `cold_start_many`'s fallback pools. + /// + /// **Shot 1:** for each fallback pool whose adapter planner declares a discover + /// view-call — the signal for a layout-free read-set that must be discovered + /// (Curve `get_dy`, Balancer `getPoolTokens`); named/derived-slot protocols + /// declare none and are skipped — derive that call's storage read-set with one + /// `eth_createAccessList` through the [`EvmCache`]'s own fetcher. **Shot 2:** + /// the cache's batch storage fetcher loads every derived read-set as one + /// prewarm request. + /// + /// This only *prewarms* the cache: the authoritative read-set and all + /// metadata/status finalization still come from the caller's subsequent + /// per-pool [`cold_start`](Self::cold_start), whose discover call now executes + /// warm (no serial faulting) and whose `finish` persists everything as usual. + /// So an incomplete access list self-heals (the warm discover captures the true + /// set; any missed slot faults once) and there is no correctness risk. + fn prime_fallback_read_sets( + &self, + pools: &[PoolRegistration], + is_fallback: &[bool], + cache: &mut EvmCache, + ) { + // Collect each fallback pool's discover call. A verify-only / named-slot + // planner (Uniswap V2/V3, Solidly, or a Curve/Balancer pool whose read-set + // is already known) declares no discover call and is skipped — so an + // all-fast bootstrap does no extra work here. + let mut discover_calls: Vec = Vec::new(); + for (index, pool) in pools.iter().enumerate() { + if !is_fallback[index] { + continue; + } + let Some(adapter) = self.adapter(pool.protocol()) else { + continue; + }; + let Ok(mut planner) = adapter.cold_start_planner(pool, ColdStartPolicy::Eager) else { + continue; + }; + let plan = planner.initial_plan(&UpstreamStateView( + &*cache as &dyn evm_fork_cache::StateView, + )); + if let Some(call) = plan.discover.into_iter().next() { + discover_calls.push(call); + } + } + if discover_calls.is_empty() { + return; + } + + // Shot 1: derive each discover call's read-set through the cache-owned + // fetcher. The upstream cache handles pinned-baseFee gas pricing and + // null-tolerant access-list decoding. Any per-pool failure simply skips + // priming for that pool; the local cold-start below remains authoritative. + let Some(access_list_fetcher) = cache.access_list_fetcher().cloned() else { + return; + }; + let block = cache.block(); + let mut requests: Vec<(Address, U256)> = Vec::new(); + for call in &discover_calls { + let access_call = AccessListCall::new(call.from, call.to, call.calldata.clone()); + if let Ok(access) = access_list_fetcher(access_call, block) { + requests.extend(access.slots); + } + } + if requests.is_empty() { + return; + } + requests.sort_unstable(); + requests.dedup(); + let _ = cache.prewarm_slots(&requests); + } } /// Attach the verified-code-seed results to the [`ColdStartReport`] carried by a diff --git a/src/adapters/curve.rs b/src/adapters/curve.rs index 77fd469..c58622e 100644 --- a/src/adapters/curve.rs +++ b/src/adapters/curve.rs @@ -1017,7 +1017,9 @@ mod tests { .with_coins([Address::repeat_byte(0x01), Address::repeat_byte(0x02)]) .with_code_seed(runtime.clone()), )); - let seeds = adapter.code_seeds(&seeded).expect("code_seeds never errors"); + let seeds = adapter + .code_seeds(&seeded) + .expect("code_seeds never errors"); assert_eq!(seeds, vec![AdapterCodeSeed::new(pool, runtime)]); // No code_seed (the default): no seeds, not an error. diff --git a/src/adapters/registry.rs b/src/adapters/registry.rs index f0ca2d8..b05b461 100644 --- a/src/adapters/registry.rs +++ b/src/adapters/registry.rs @@ -18,6 +18,13 @@ pub struct AdapterRegistry { /// runtime bytecode (an optimization over the lazy real-code fetch). /// Defaults to `true`; opt out via [`with_code_seeding`](Self::with_code_seeding). pub(crate) code_seeding: bool, + /// Whether [`cold_start_many`](Self::cold_start_many) / [`cold_start_primed`](Self::cold_start_primed) + /// derive an unknown read-set with one `eth_createAccessList` call before + /// warming (the two-shot first-boot fast path). Defaults to `true`; opt out + /// via [`with_access_list_discovery`](Self::with_access_list_discovery) on a + /// provider that lacks `eth_createAccessList` (a per-pool failure already + /// falls back to local discovery, so this is only a round-trip optimization). + pub(crate) access_list_discovery: bool, } impl Default for AdapterRegistry { @@ -26,6 +33,7 @@ impl Default for AdapterRegistry { adapters: HashMap::new(), pools: HashMap::new(), code_seeding: true, + access_list_discovery: true, } } } @@ -47,6 +55,21 @@ impl AdapterRegistry { self } + /// Enable or disable `eth_createAccessList`-based read-set discovery during + /// [`cold_start_many`](Self::cold_start_many) / [`cold_start_primed`](Self::cold_start_primed). + /// + /// When `true` (the default), a layout-free pool with no known read-set + /// (Curve / Balancer on first boot) has its `get_dy` / `getPoolTokens` + /// read-set derived by a single `eth_createAccessList` call and bulk-loaded, + /// so the subsequent cold-start runs warm instead of faulting each slot + /// serially. A provider that lacks `eth_createAccessList`, or a per-pool + /// failure, falls back to local discovery automatically — so disabling this + /// only avoids the (cheap, self-recovering) attempt. + pub fn with_access_list_discovery(mut self, enabled: bool) -> Self { + self.access_list_discovery = enabled; + self + } + /// Register a pool. Errors [`RegistryError::DuplicatePool`] if its key is /// already registered. pub fn register_pool(&mut self, registration: PoolRegistration) -> Result<(), RegistryError> { diff --git a/tests/access_list_discovery_rpc.rs b/tests/access_list_discovery_rpc.rs new file mode 100644 index 0000000..0beb815 --- /dev/null +++ b/tests/access_list_discovery_rpc.rs @@ -0,0 +1,165 @@ +//! Env-gated (`#[ignore]`) live parity for the `eth_createAccessList` two-shot +//! cold-warming path — MANAGER RUNS THIS. +//! +//! Given `E2E_RPC_URL` (an archive node), this forks mainnet at a pinned block +//! and asserts that [`AdapterRegistry::cold_start_primed`] (which derives an +//! unknown read-set with one `eth_createAccessList`, bulk-loads it, then runs the +//! discover warm) reaches `Ready` and warms **exactly the same read-set** as the +//! plain local-discovery [`AdapterRegistry::cold_start`], for real Curve pools. +//! This proves the remote access-list path integrates with a real provider and is +//! faithful to local discovery. (The *latency* win is demonstrated by +//! `examples/curve_cold_start_phases.rs`; an under-warm would simply fault the +//! missing slots during the warm discover — still correct here, just slower there.) +//! +//! Not run in CI (no network). Build-checked via +//! `cargo build --tests --test access_list_discovery_rpc`. To run: +//! ```text +//! E2E_RPC_URL= cargo test --test access_list_discovery_rpc -- --ignored --nocapture +//! ``` + +use std::sync::Arc; + +use alloy_eips::{BlockId, BlockNumberOrTag}; +use alloy_network::AnyNetwork; +use alloy_primitives::{Address, U256, address}; +use alloy_provider::RootProvider; +use anyhow::{Context, Result}; + +use evm_amm_state::adapters::{ + AdapterRegistry, ColdStartOutcome, ColdStartPolicy, CurveAdapter, CurveMetadata, CurveVariant, + PoolKey, PoolRegistration, ProtocolMetadata, +}; +use evm_fork_cache::cache::EvmCache; + +// Same pinned block + pools as `adapter_swap_sim_rpc.rs`. +const FORK_BLOCK: u64 = 20_000_000; +const CURVE_3POOL: Address = address!("bEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7"); +const DAI: Address = address!("6B175474E89094C44Da98b954EedeAC495271d0F"); +const USDC: Address = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); +const USDT: Address = address!("dAC17F958D2ee523a2206206994597C13D831ec7"); +const TRICRYPTO2: Address = address!("D51a44d3FaE010294C616388b506AcdA1bfAAE46"); +const WBTC: Address = address!("2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"); +const WETH: Address = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); + +fn rpc_url() -> Option { + std::env::var("E2E_RPC_URL").ok() +} + +async fn provider(url: &str) -> Result>> { + Ok(Arc::new( + RootProvider::::connect(url) + .await + .context("connect RPC url")?, + )) +} + +fn curve_registry() -> AdapterRegistry { + let mut registry = AdapterRegistry::new(); + registry + .register_adapter(Arc::new(CurveAdapter::default())) + .expect("register curve adapter"); + registry +} + +fn sorted_discovered_slots(reg: &PoolRegistration) -> Vec { + match ®.metadata { + ProtocolMetadata::Curve(m) => { + let mut slots = m.discovered_slots.clone(); + slots.sort_unstable(); + slots + } + _ => Vec::new(), + } +} + +/// Cold-start `pool` twice from an unknown read-set — once via plain local +/// discovery, once via `cold_start_primed` (access-list-derived) — and assert both +/// reach `Ready` and warm the identical read-set. +async fn assert_primed_matches_local( + url: &str, + pool: Address, + coins: Vec
, + variant: CurveVariant, +) -> Result<()> { + let block = BlockId::Number(BlockNumberOrTag::Number(FORK_BLOCK)); + let registration = || { + PoolRegistration::new(PoolKey::Curve(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::Curve( + CurveMetadata::default() + .with_coins(coins.clone()) + .with_variant(variant), + )) + }; + + // Local discovery (the baseline). + let local_provider = provider(url).await?; + let mut local_cache = EvmCache::at_block(local_provider, block).await; + let mut local = registration(); + let local_outcome = + curve_registry().cold_start(&mut local, &mut local_cache, ColdStartPolicy::Eager)?; + assert!( + matches!(local_outcome, ColdStartOutcome::Ready(_)), + "local cold_start should reach Ready for {pool}, got {local_outcome:?}" + ); + + // Access-list-primed (the two-shot path). + let primed_provider = provider(url).await?; + let mut primed_cache = EvmCache::at_block(primed_provider, block).await; + let mut primed = registration(); + let primed_outcome = curve_registry() + .cold_start_primed(&mut primed, &mut primed_cache, ColdStartPolicy::Eager) + .await?; + assert!( + matches!(primed_outcome, ColdStartOutcome::Ready(_)), + "primed cold_start should reach Ready for {pool}, got {primed_outcome:?}" + ); + + let local_slots = sorted_discovered_slots(&local); + let primed_slots = sorted_discovered_slots(&primed); + assert!( + !primed_slots.is_empty(), + "a non-empty read-set must be discovered for {pool}" + ); + assert_eq!( + primed_slots, local_slots, + "access-list-primed read-set must equal the local-discovery read-set for {pool}" + ); + eprintln!( + "{pool}: primed read-set matches local ({} slots)", + primed_slots.len() + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires E2E_RPC_URL archive node; run with --ignored"] +async fn access_list_primed_matches_local_stableswap_3pool() -> Result<()> { + let Some(url) = rpc_url() else { + eprintln!("E2E_RPC_URL unset; skipping"); + return Ok(()); + }; + assert_primed_matches_local( + &url, + CURVE_3POOL, + vec![DAI, USDC, USDT], + CurveVariant::StableSwap, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires E2E_RPC_URL archive node; run with --ignored"] +async fn access_list_primed_matches_local_cryptoswap_tricrypto2() -> Result<()> { + let Some(url) = rpc_url() else { + eprintln!("E2E_RPC_URL unset; skipping"); + return Ok(()); + }; + assert_primed_matches_local( + &url, + TRICRYPTO2, + vec![USDT, WBTC, WETH], + CurveVariant::CryptoSwap, + ) + .await +} diff --git a/tests/cold_start_adoption.rs b/tests/cold_start_adoption.rs index 7d0a2d1..46ba232 100644 --- a/tests/cold_start_adoption.rs +++ b/tests/cold_start_adoption.rs @@ -30,8 +30,8 @@ use evm_amm_state::adapters::{ V3ImmutablePatchValues, V3Metadata, uniswap_v2_pair_runtime_code_hash, uniswap_v3_code_seed, uniswap_v3_max_liquidity_per_tick, }; -use evm_fork_cache::AccountFieldsSample; use evm_fork_cache::cache::{AccountFieldsFetchFn, CodeSeedState, EvmCache, StorageBatchFetchFn}; +use evm_fork_cache::{AccountFieldsSample, StorageAccessList}; use revm::state::{AccountInfo, Bytecode}; // --- helpers (kept local so this manager file owns its fixtures) --- @@ -1745,6 +1745,147 @@ async fn curve_cold_start_verify_only_unfetchable_slot_needs_repair() -> Result< Ok(()) } +// Two-shot first-boot warming (`cold_start_primed`): a Curve pool with NO known +// read-set has its `get_dy` read-set derived by one `eth_createAccessList` (shot +// 1) and bulk-loaded (shot 2), so the follow-up discover runs WARM. The only two +// provider requests queued are createAccessList + the one bundled load; the +// discover does not fault slot 0 over RPC (it was prewarmed) — reaching Ready +// with an empty request queue proves the serial faulting was eliminated. +#[tokio::test(flavor = "multi_thread")] +async fn curve_cold_start_primed_access_list_avoids_serial_faults() -> Result<()> { + let pool = Address::repeat_byte(0xca); + let fresh = U256::from(999_000_u64); + + let asserter = Asserter::new(); + let provider = Arc::new(RootProvider::::new(RpcClient::mocked( + asserter.clone(), + ))); + let mut cache = EvmCache::new(provider.clone()).await; + + let access_list_calls = Arc::new(AtomicUsize::new(0)); + let access_list_calls_for_fetcher = access_list_calls.clone(); + cache.set_access_list_fetcher(Arc::new(move |call, _block| { + access_list_calls_for_fetcher.fetch_add(1, Ordering::SeqCst); + assert_eq!(call.to, pool, "priming must ask for the pool discover call"); + let mut access = StorageAccessList::default(); + access.accounts.insert(pool); + access.slots.insert((pool, U256::ZERO)); + Ok(access) + })); + + // Runtime so the warm discover's `get_dy` executes + the account is present + // (no account fetch); ZERO is the discover call's gas-credited beneficiary; + // the stub fetcher serves the verify round offline. + install_default_account(&mut cache, Address::ZERO); + install_vault_runtime( + &mut cache, + pool, + include_str!("fixtures/mock_curve_pool_runtime.hex"), + ); + cache.set_storage_batch_fetcher(fetcher_with_failures( + HashMap::from([((pool, U256::ZERO), fresh)]), + Vec::new(), + )); + + let mut registry = AdapterRegistry::new(); + registry.register_adapter(Arc::new(CurveAdapter::default()))?; + let mut registration = PoolRegistration::new(PoolKey::Curve(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::Curve( + CurveMetadata::default() + .with_coins(vec![Address::repeat_byte(0x01), Address::repeat_byte(0x02)]) + .with_variant(CurveVariant::StableSwap), + )); + + let outcome = registry + .cold_start_primed(&mut registration, &mut cache, ColdStartPolicy::Eager) + .await?; + + assert!( + matches!(outcome, ColdStartOutcome::Ready(_)), + "primed cold-start should reach Ready, got {outcome:?}" + ); + match registration.metadata { + ProtocolMetadata::Curve(ref m) => assert!( + m.discovered_slots.contains(&U256::ZERO), + "the warm discover must persist the read-set, got {:?}", + m.discovered_slots + ), + ref other => panic!("expected Curve metadata, got {other:?}"), + } + assert_eq!( + cache.cached_storage_value(pool, U256::ZERO), + Some(fresh), + "slot 0 warmed to the fresh value" + ); + assert_eq!( + access_list_calls.load(Ordering::SeqCst), + 1, + "the access-list read-set should be derived once through the cache fetcher" + ); + Ok(()) +} + +// Graceful fallback: when the provider lacks `eth_createAccessList` (here, the +// mock has no queued response, so it errors), priming is skipped and the pool +// finalizes through the normal local discovery — still Ready. +#[tokio::test(flavor = "multi_thread")] +async fn curve_cold_start_primed_falls_back_when_access_list_unsupported() -> Result<()> { + let pool = Address::repeat_byte(0xcb); + let stale = U256::from(1_u64); + let fresh = U256::from(555_000_u64); + + let asserter = Asserter::new(); + let provider = Arc::new(RootProvider::::new(RpcClient::mocked( + asserter.clone(), + ))); + let mut cache = EvmCache::new(provider.clone()).await; + + // No queued response → createAccessList errors → priming skipped. The local + // discovery then runs: pre-seed slot 0 so its `get_dy` SLOAD does not fault, + // and the stub fetcher refreshes it in the verify round. ZERO is the discover + // call's gas-credited beneficiary. + install_default_account(&mut cache, Address::ZERO); + install_vault_runtime( + &mut cache, + pool, + include_str!("fixtures/mock_curve_pool_runtime.hex"), + ); + cache + .db_mut() + .insert_account_storage(pool, U256::ZERO, stale)?; + cache.set_storage_batch_fetcher(fetcher_with_failures( + HashMap::from([((pool, U256::ZERO), fresh)]), + Vec::new(), + )); + + let mut registry = AdapterRegistry::new(); + registry.register_adapter(Arc::new(CurveAdapter::default()))?; + let mut registration = PoolRegistration::new(PoolKey::Curve(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::Curve( + CurveMetadata::default() + .with_coins(vec![Address::repeat_byte(0x01), Address::repeat_byte(0x02)]) + .with_variant(CurveVariant::StableSwap), + )); + + let outcome = registry + .cold_start_primed(&mut registration, &mut cache, ColdStartPolicy::Eager) + .await?; + + assert!( + matches!(outcome, ColdStartOutcome::Ready(_)), + "unsupported createAccessList must fall back to local discovery and still reach Ready, got {outcome:?}" + ); + assert_eq!(registration.status, PoolStatus::Ready); + assert_eq!( + cache.cached_storage_value(pool, U256::ZERO), + Some(fresh), + "local discovery + verify refreshed the slot" + ); + Ok(()) +} + // A reverting `get_dy` discover call must be classified as a failed discovery // (NeedsRepair via re-discovery), never silently driven to Ready. #[tokio::test(flavor = "multi_thread")]