From 7ff2e6fb37c25b8968f060ef3d9f41ca6ec68f6c Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 14:09:20 +0100 Subject: [PATCH 1/3] perf(curve): verify-only cold-start when the read-set is known + optional code seed CurveColdStartPlanner now skips discovery when CurveMetadata.discovered_slots is already known (a prior discovery, a trace, or a registry): no pool-account/bytecode fetch and no cold-cache get_dy slot-faulting, just a single verify round over the known slots. This makes a known-read-set single-pool cold_start as cheap as the bundled cold_start_many storage-program path Uniswap V2/V3 use. The discover->verify path is unchanged when discovered_slots is empty. Adds optional CurveMetadata.code_seed (+ with_code_seed builder + CurveAdapter::code_seeds): a caller-supplied Vyper runtime, verified once against on-chain EXTCODEHASH (mismatch -> purged -> lazy fetch), removing the one lazy code fetch a Curve pool otherwise pays on its first simulate_swap. Additive, non-breaking. Tests: verify-only skips-discovery + unfetchable-slot repair (cold_start_adoption), code_seeds unit test (curve), positive Curve one-shot classification (bootstrap_many). Adds examples/curve_cold_start_phases.rs and updates docs (curve-adapter, README, benchmarks). 216 tests pass; clippy/doc/all-features clean. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 7 + README.md | 21 +- docs/benchmarks.md | 30 +++ docs/curve-adapter.md | 64 +++++- examples/curve_cold_start_phases.rs | 325 ++++++++++++++++++++++++++++ src/adapters/curve.rs | 177 +++++++++++++-- src/adapters/types.rs | 38 +++- tests/bootstrap_many.rs | 16 ++ tests/cold_start_adoption.rs | 112 ++++++++++ 9 files changed, 746 insertions(+), 44 deletions(-) create mode 100644 examples/curve_cold_start_phases.rs diff --git a/Cargo.toml b/Cargo.toml index 865e02d..813a5bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -255,6 +255,13 @@ required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2", "curve"] name = "trace_resync_latency" required-features = ["curve"] +# Curve cold-start phase breakdown: discovery (slow first boot) vs verify-only +# cold_start vs cold_start_many, once the read-set is known. Env-gated; defaults +# to a public Ethereum endpoint when E2E_RPC_URL is unset. +[[example]] +name = "curve_cold_start_phases" +required-features = ["curve"] + # End-to-end arbitrage examples (env-gated; need an archive RPC to warm state). [[example]] name = "arbitrage_cross_dex" diff --git a/README.md b/README.md index 15fc29b..966a3cf 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,13 @@ 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; Balancer and Curve pools have no -embedded seed and simply fetch their runtime code lazily on first simulate. Since -seeding is a pure optimization over that lazy fetch, this is only a latency -difference, never a correctness one. +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 @@ -169,10 +172,12 @@ per-pair fallback. `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), 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. Combined with token-basket discovery, +`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. diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 5ae1ebe..4efa1d7 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -111,6 +111,36 @@ Interpretation: `debug_traceBlockByNumber` integration should populate it from traces, avoiding the view-call discover round and keeping the one-shot refresh path. +### Curve cold-start: discovery vs a known read-set + +A Curve pool's *first* cold start is a discover→verify run: it fetches the pool's +Vyper runtime and executes `get_dy` in a local revm over a cold cache, lazily +faulting in each slot it SLOADs. That first-discovery cost — not warmed quoting — +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): + +- **verify-only `cold_start`** — the planner skips discovery and warms exactly + the known slots in a single verify round; +- **`cold_start_many`** — the same read-set becomes one bundled storage program, + 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: + +```bash +E2E_RPC_URL= cargo run --release --example curve_cold_start_phases +``` + +The optional `CurveMetadata::with_code_seed` removes the one lazy code fetch a +Curve pool otherwise pays on its first quote, matching the fully-offline V2/V3 +profile after bootstrap. + ### Event-time trace resync [`examples/trace_resync_latency.rs`](../examples/trace_resync_latency.rs) diff --git a/docs/curve-adapter.md b/docs/curve-adapter.md index 6ca622f..969382c 100644 --- a/docs/curve-adapter.md +++ b/docs/curve-adapter.md @@ -26,19 +26,29 @@ reimplemented Curve math**. ```rust use evm_amm_state::adapters::{CurveMetadata, CurveVariant, PoolKey, PoolRegistration, ProtocolMetadata}; +// Minimal: coins (index order) + dialect. Cold-start discovers the read-set. let reg = PoolRegistration::new(PoolKey::Curve(pool_address)) .with_state_address(pool_address) - .with_metadata(ProtocolMetadata::Curve(CurveMetadata { - // Coins in index order; coins[i] is the get_dy index `i`. Config-supplied - // (static pool identity); drives the simulate_swap token -> index mapping. - coins: vec![dai, usdc, usdt], - // Populated by cold-start (the get_dy read-set). Leave empty. - discovered_slots: Vec::new(), - // The dialect — selects the get_dy ABI and the event set. - variant: CurveVariant::StableSwap, - })); + .with_metadata(ProtocolMetadata::Curve( + CurveMetadata::default() + // coins[i] is the get_dy index `i`; config-supplied static pool + // identity, drives the simulate_swap token -> index mapping. + .with_coins(vec![dai, usdc, usdt]) + // The dialect — selects the get_dy ABI and the event set. + .with_variant(CurveVariant::StableSwap), + )); + +// Fast reboot: pre-fill the read-set (from a prior discovery / trace / registry) +// to skip discovery, and optionally seed the pool runtime. See "Cold-start". +// CurveMetadata::default() +// .with_coins(vec![dai, usdc, usdt]) +// .with_discovered_slots(known_slots) // verify-only cold_start + cold_start_many +// .with_code_seed(pool_runtime) // no lazy code fetch at first quote ``` +(`CurveMetadata` is `#[non_exhaustive]`; construct it via `default()` + the +`with_*` builders rather than a struct literal.) + `CurveVariant` (defaults to `StableSwap`, so classic + NG pools need no flag): | Variant | Use for | `get_dy` indices | `TokenExchange` | Liquidity events | @@ -52,23 +62,53 @@ quote path (differing only by the 3-arg `RemoveLiquidityOne`, which both route); CryptoSwap/Tricrypto-NG share the `uint256` quote path (differing only in events). -## Cold-start — discover → verify +## Cold-start — discover → verify, or verify-only A real Curve pool has **no predictable balance-slot layout** (a probe confirmed `balances[]` is not at a fixed slot — it varies by Vyper build), so the planner -does not hand-code slots. Instead it mirrors `BalancerV2ColdStartPlanner`: +does not hand-code slots. It runs in one of two modes. + +**Discover → verify** — the read-set is unknown (`discovered_slots` empty), +mirroring `BalancerV2ColdStartPlanner`: 1. **Discover** — run `get_dy(0, 1, DISCOVER_DX)` against the pool with `restrict_to=[pool]`, capturing the exact storage slots it SLOADs (balances + amplification + fee, wherever they live). The discover call uses the variant's `get_dy` ABI (a CryptoSwap pool reverts the `int128` form). 2. **Verify** — authoritatively warm those captured slots. -3. **finish** — persist `coins` + `discovered_slots` + `variant`, status `Ready`. +3. **finish** — persist `coins` + `discovered_slots` + `variant` (+ any + `code_seed`), status `Ready`. 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. +**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` +faulting — and warms exactly the known slots in a **single verify round**. This +is what makes a known-read-set `cold_start` as cheap as the bundled +`cold_start_many` storage-program path (the same one-shot hydration Uniswap V2/V3 +use), and it makes the pool eligible for `cold_start_many` / +`supports_one_shot_hydration`. A stale/incomplete set is safe: verify refreshes +what it has and the first `simulate_swap` lazily faults anything missing. See +[`examples/curve_cold_start_phases.rs`](../examples/curve_cold_start_phases.rs) +for a live discovery-vs-verify-only-vs-`cold_start_many` breakdown. + +### Bytecode seeding (optional) + +Curve pools are per-pool Vyper builds with **no shared or renderable template** +(unlike Uniswap V2's shared pair runtime or V3's rendered template), so the crate +embeds no Curve seed. A caller that already knows a pool's runtime can attach it +via [`CurveMetadata::with_code_seed`]: cold-start (and `cold_start_many`) verify +it once against the on-chain `EXTCODEHASH` — a mismatch is purged and the pool +falls back to lazily fetching the real code, so a wrong seed is a latency +question, never a correctness one. Seeding removes the one lazy code fetch a Curve +pool otherwise pays on its first `simulate_swap`, matching the fully-offline +V2/V3 profile after bootstrap. + +[`CurveMetadata::with_code_seed`]: https://docs.rs/evm-amm-state/latest/evm_amm_state/adapters/struct.CurveMetadata.html + ## Reactive — resync (not event-sourcing) **Curve state cannot be kept current purely from events** (unlike Uniswap V2, diff --git a/examples/curve_cold_start_phases.rs b/examples/curve_cold_start_phases.rs new file mode 100644 index 0000000..8bb7f2b --- /dev/null +++ b/examples/curve_cold_start_phases.rs @@ -0,0 +1,325 @@ +//! Curve cold-start phase breakdown: discovery vs verify-only vs `cold_start_many`. +//! +//! Curve's *first* cold start is a discover→verify run — it fetches the pool's +//! Vyper runtime and runs `get_dy` in a local revm over a cold cache, lazily +//! faulting in each SLOAD it touches. That first-discovery cost is what makes a +//! cold Curve boot slower than Uniswap V2/V3, whose hot state is a known slot set +//! (or a tick-bitmap program) hydrated in one bundled `eth_call`. +//! +//! Once a Curve pool's read-set is known, the crate closes that gap two ways — +//! both measured here against the same real pool: +//! +//! - **verify-only `cold_start`**: pre-populate `CurveMetadata.discovered_slots` +//! and the planner skips discovery, warming exactly those slots in one verify +//! round; +//! - **`cold_start_many`**: the same known read-set becomes a single bundled +//! storage program — the identical one-shot path Uniswap V2/V3 take. +//! +//! It also shows the optional `CurveMetadata.code_seed`: attaching the pool's +//! runtime removes the one lazy code fetch a Curve pool otherwise pays on its +//! first `simulate_swap`, making it fully offline after bootstrap like V2/V3. +//! +//! ```text +//! E2E_RPC_URL= cargo run --release --example curve_cold_start_phases +//! ``` +//! +//! If `E2E_RPC_URL` is unset, the runner uses `https://ethereum.publicnode.com` +//! so it stays runnable from a clean shell. Results are provider-dependent; use a +//! paid/archive endpoint for stable numbers. `CURVE_PHASES_ITERS` sets iterations. + +use std::future::Future; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use alloy_eips::{BlockId, BlockNumberOrTag}; +use alloy_network::AnyNetwork; +use alloy_primitives::{Address, U256, address}; +use alloy_provider::{Provider, RootProvider}; +use alloy_rpc_client::RpcClient; +use alloy_transport_http::Http; +use anyhow::{Context, Result}; +use evm_amm_state::adapters::{ + AdapterRegistry, ColdStartOutcome, ColdStartPolicy, CurveAdapter, CurveMetadata, CurveVariant, + PoolKey, PoolRegistration, ProtocolMetadata, supports_one_shot_hydration, +}; +use evm_fork_cache::cache::EvmCache; + +const DEFAULT_RPC_URL: &str = "https://ethereum.publicnode.com"; +const DEFAULT_ITERS: usize = 3; + +// Curve Tricrypto2 (USDT/WBTC/WETH), the CryptoSwap v2 pool the showcase warms. +const CURVE_TRICRYPTO2: Address = address!("D51a44d3FaE010294C616388b506AcdA1bfAAE46"); +const USDT: Address = address!("dAC17F958D2ee523a2206206994597C13D831ec7"); +const WBTC: Address = address!("2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"); +const WETH: Address = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); + +type SharedProvider = Arc>; + +/// Wall-clock samples for one cold-start path, plus a one-line description. +struct PhaseStats { + durations: Vec, + details: String, +} + +impl PhaseStats { + fn median_ms(&self) -> f64 { + let mut durations = self.durations.clone(); + durations.sort_unstable(); + durations[durations.len() / 2].as_secs_f64() * 1000.0 + } + + fn min_ms(&self) -> f64 { + self.durations + .iter() + .min() + .map(|d| d.as_secs_f64() * 1000.0) + .unwrap_or_default() + } + + fn max_ms(&self) -> f64 { + self.durations + .iter() + .max() + .map(|d| d.as_secs_f64() * 1000.0) + .unwrap_or_default() + } +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<()> { + let url = std::env::var("E2E_RPC_URL").unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()); + let iterations = std::env::var("CURVE_PHASES_ITERS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_ITERS); + + let provider = provider(&url)?; + let latest = provider.get_block_number().await.context("get block")?; + let pinned = latest.saturating_sub(8); + let block = BlockId::Number(BlockNumberOrTag::Number(pinned)); + + println!("# Curve cold-start phase breakdown\n"); + println!("- rpc: {}", redact_url(&url)); + println!("- pool: Tricrypto2 {CURVE_TRICRYPTO2}"); + println!("- block: {pinned}"); + println!("- iterations: {iterations}\n"); + + // First, one discovery run to capture the read-set the fast paths reuse. + let mut discovered_slots = discover_once(provider.clone(), block).await?; + discovered_slots.sort_unstable(); + discovered_slots.dedup(); + if discovered_slots.is_empty() { + println!( + "Discovery captured no slots (is this an archive node at a recent block?). \ + Cannot measure the fast paths; aborting." + ); + return Ok(()); + } + println!( + "Discovered read-set: {} slots (reused by both fast paths below).", + discovered_slots.len() + ); + // The slot KEYS are fixed by the pool's Vyper layout (block-independent), so + // they can be captured once and persisted. Print them paste-ready for a + // `CurveMetadata::with_discovered_slots([..])` in a consumer (e.g. a demo). + println!("Persist these to skip discovery on later boots:"); + for slot in &discovered_slots { + println!(" U256::from_str_radix(\"{slot:x}\", 16).unwrap(),"); + } + println!(); + + // 1) The slow first boot: discover -> verify (fetches code, faults SLOADs). + let discovery = measure(iterations, || { + let provider = provider.clone(); + async move { + let mut cache = cache(provider, block).await; + let mut reg = curve_registration(Vec::new(), None); + let outcome = + curve_registry().cold_start(&mut reg, &mut cache, ColdStartPolicy::Eager)?; + ensure_ready(&outcome, "discovery cold_start")?; + Ok(()) + } + }) + .await + .map(|durations| PhaseStats { + durations, + details: "discover -> verify: fetch code + fault get_dy read-set".to_string(), + })?; + + // 2) verify-only cold_start: the read-set is known, so discovery is skipped. + let verify_only = { + let slots = discovered_slots.clone(); + measure(iterations, || { + let provider = provider.clone(); + let slots = slots.clone(); + async move { + let mut cache = cache(provider, block).await; + let mut reg = curve_registration(slots, None); + let outcome = + curve_registry().cold_start(&mut reg, &mut cache, ColdStartPolicy::Eager)?; + ensure_ready(&outcome, "verify-only cold_start")?; + Ok(()) + } + }) + .await + .map(|durations| PhaseStats { + durations, + details: "single verify round over the known slots (no discovery)".to_string(), + })? + }; + + // 3) cold_start_many: the known read-set as one bundled storage program. + let bundled = { + let slots = discovered_slots.clone(); + measure(iterations, || { + let provider = provider.clone(); + let slots = slots.clone(); + async move { + let mut cache = cache(provider.clone(), block).await; + let mut pools = vec![curve_registration(slots, None)]; + debug_assert!( + supports_one_shot_hydration(&pools[0]), + "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) + .await?; + ensure_ready(&outcomes[0], "cold_start_many")?; + Ok(()) + } + }) + .await + .map(|durations| PhaseStats { + durations, + details: "one bundled storage program (the V2/V3 fast path)".to_string(), + })? + }; + + print_row("discovery cold_start (cold first boot)", &discovery); + 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.", + base / verify_only.median_ms().max(f64::MIN_POSITIVE), + base / bundled.median_ms().max(f64::MIN_POSITIVE), + ); + + // Optional bytecode seed: fetch the pool runtime and show it verifies once, + // so a later first quote needs no lazy code fetch (fully offline like V2/V3). + let code = provider + .get_code_at(CURVE_TRICRYPTO2) + .block_id(block) + .await + .context("eth_getCode for Tricrypto2")?; + let mut cache = cache(provider.clone(), block).await; + let mut seeded = curve_registration(discovered_slots.clone(), Some(code.clone())); + let outcome = curve_registry().cold_start(&mut seeded, &mut cache, ColdStartPolicy::Eager)?; + let verified = match &outcome { + ColdStartOutcome::Ready(report) => report + .code_seeds + .as_ref() + .map(|seeds| seeds.verified.len()) + .unwrap_or(0), + _ => 0, + }; + println!( + "\nBytecode seed: attached {} bytes of pool runtime; cold-start verified {} seed(s) \ + against on-chain code. With the seed, the first simulate_swap needs no lazy code fetch.", + code.len(), + verified, + ); + + Ok(()) +} + +fn provider(url: &str) -> Result { + let client = reqwest::Client::builder() + .gzip(true) + .build() + .context("build reqwest client")?; + let http = Http::with_client(client, url.parse().context("parse RPC URL")?); + Ok(Arc::new(RootProvider::::new(RpcClient::new( + http, false, + )))) +} + +async fn cache(provider: SharedProvider, block: BlockId) -> EvmCache { + EvmCache::at_block(provider, block).await +} + +/// Run `iterations` timed passes of `f`, each a fresh cold start. +async fn measure(iterations: usize, mut f: F) -> Result> +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut samples = Vec::with_capacity(iterations); + for _ in 0..iterations { + let start = Instant::now(); + f().await?; + samples.push(start.elapsed()); + } + Ok(samples) +} + +/// One discovery cold start, returning the captured `get_dy` read-set. +async fn discover_once(provider: SharedProvider, block: BlockId) -> Result> { + let mut cache = cache(provider, block).await; + let mut reg = curve_registration(Vec::new(), None); + let outcome = curve_registry().cold_start(&mut reg, &mut cache, ColdStartPolicy::Eager)?; + ensure_ready(&outcome, "discovery cold_start")?; + Ok(match ®.metadata { + ProtocolMetadata::Curve(m) => m.discovered_slots.clone(), + _ => Vec::new(), + }) +} + +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) + .with_variant(CurveVariant::CryptoSwap); + if let Some(code) = code_seed { + metadata = metadata.with_code_seed(code); + } + PoolRegistration::new(PoolKey::Curve(CURVE_TRICRYPTO2)) + .with_state_address(CURVE_TRICRYPTO2) + .with_metadata(ProtocolMetadata::Curve(metadata)) +} + +fn curve_registry() -> AdapterRegistry { + let mut registry = AdapterRegistry::new(); + registry + .register_adapter(Arc::new(CurveAdapter::default())) + .expect("register curve adapter"); + registry +} + +fn ensure_ready(outcome: &ColdStartOutcome, label: &str) -> Result<()> { + match outcome { + ColdStartOutcome::Ready(_) | ColdStartOutcome::ReadyWithDeferred(_, _) => Ok(()), + other => Err(anyhow::anyhow!("{label} did not reach Ready: {other:?}")), + } +} + +fn print_row(name: &str, stats: &PhaseStats) { + println!( + "- {name:<44} {:>7.1} ms (min..max {:.1}..{:.1}) [{}]", + stats.median_ms(), + stats.min_ms(), + stats.max_ms(), + stats.details, + ); +} + +fn redact_url(url: &str) -> String { + match url.split_once("://") { + Some((scheme, rest)) => { + let host = rest.split('/').next().unwrap_or(rest); + format!("{scheme}://{host}/...") + } + None => "".to_string(), + } +} diff --git a/src/adapters/curve.rs b/src/adapters/curve.rs index 976a444..77fd469 100644 --- a/src/adapters/curve.rs +++ b/src/adapters/curve.rs @@ -32,6 +32,7 @@ //! //! [`docs/curve-adapter.md`]: https://github.com/KaiCode2/evm-amm-state/blob/main/docs/curve-adapter.md +use super::bytecode::{AdapterCodeSeed, BytecodeTemplateError}; use super::cold_start::{ AdapterColdStartPlanner, ColdStartCall, ColdStartPlan, ColdStartResults, ColdStartRunReport, ColdStartStep, SlotFetch, @@ -260,20 +261,57 @@ impl AmmAdapter for CurveAdapter { )); }; - // Preserve the config-supplied coins + variant across cold-start so - // `finish` can re-emit them alongside the discovered slots. The variant - // also drives the discover call's `get_dy` ABI (a CryptoSwap pool - // reverts the int128 discover, which would be a spurious DiscoverFailed). - let (coins, variant) = match &pool.metadata { - ProtocolMetadata::Curve(metadata) => (metadata.coins.clone(), metadata.variant), - _ => (Vec::new(), CurveVariant::StableSwap), + // Preserve the config-supplied coins + variant + code_seed across + // cold-start so `finish` can re-emit them alongside the discovered slots. + // The variant also drives the discover call's `get_dy` ABI (a CryptoSwap + // pool reverts the int128 discover, a spurious DiscoverFailed). A + // non-empty `discovered_slots` means the read-set is already known (a + // prior discovery / trace / registry), so the planner runs a verify-only + // fast path instead of rediscovering — see [`CurveColdStartPlanner`]. + let (coins, variant, known_slots, code_seed) = match &pool.metadata { + ProtocolMetadata::Curve(metadata) => ( + metadata.coins.clone(), + metadata.variant, + metadata.discovered_slots.clone(), + metadata.code_seed.clone(), + ), + _ => (Vec::new(), CurveVariant::StableSwap, Vec::new(), None), }; Ok(Box::new(CurveColdStartPlanner::new( - address, coins, variant, policy, + address, + coins, + variant, + known_slots, + code_seed, + policy, ))) } + /// Return the caller-supplied runtime bytecode seed, if any. + /// + /// Curve pools are per-pool Vyper builds with no shared or renderable + /// template, so unlike Uniswap V2/V3 the crate embeds no canonical seed. A + /// caller that already knows a pool's runtime can attach it via + /// [`CurveMetadata::code_seed`], and cold-start verifies it once against the + /// on-chain `EXTCODEHASH` (mismatch → purged → lazy real code, never a + /// correctness risk). Missing metadata / an address-less key / no seed all + /// return `Ok(vec![])` — simply not seedable, not an error. + fn code_seeds( + &self, + pool: &PoolRegistration, + ) -> Result, BytecodeTemplateError> { + let ProtocolMetadata::Curve(metadata) = &pool.metadata else { + return Ok(Vec::new()); + }; + match (&metadata.code_seed, pool.key.address()) { + (Some(code), Some(address)) if !code.is_empty() => { + Ok(vec![AdapterCodeSeed::new(address, code.clone())]) + } + _ => Ok(Vec::new()), + } + } + fn decode_event( &self, pool: &PoolRegistration, @@ -498,19 +536,27 @@ enum CurveRepair { BalancesUnfetched, } -/// Cold-start planner for a Curve StableSwap plain pool: a discover → verify run. +/// Cold-start planner for a Curve plain pool, in one of two modes: /// -/// A real Curve pool's `get_dy` read-set (balances + amplification + fee) lives -/// behind a non-predictable Vyper storage layout, so the planner cannot name the -/// slots up front. Instead round 1 runs a `get_dy(0, 1, DISCOVER_DX)` call on the -/// pool (`restrict_to = [pool]`) and captures the `(pool, slot)` pairs it SLOADs. -/// Round 2 authoritatively verifies exactly those discovered slots so the live -/// read-set is warmed for a subsequent `simulate_swap`. +/// - **Discover → verify** (the read-set is unknown). A real Curve pool's +/// `get_dy` read-set (balances + amplification + fee) lives behind a +/// non-predictable Vyper storage layout, so the planner cannot name the slots +/// up front. Round 1 runs a `get_dy(0, 1, DISCOVER_DX)` call on the pool +/// (`restrict_to = [pool]`) and captures the `(pool, slot)` pairs it SLOADs; +/// round 2 authoritatively verifies exactly those discovered slots so the live +/// read-set is warmed for a subsequent `simulate_swap`. +/// - **Verify-only** (the read-set is already known — `CurveMetadata` +/// [`discovered_slots`](super::CurveMetadata::discovered_slots) was +/// pre-populated from a prior discovery, a block trace, or a registry). The +/// planner skips discovery entirely — no pool-account/bytecode fetch, no local +/// `get_dy` faulting over a cold cache — and runs a single verify round over +/// the known slots. This is what makes a known-read-set `cold_start` as cheap +/// as the bundled [`cold_start_many`](super::AdapterRegistry::cold_start_many) +/// storage-program path. /// -/// The flow runs for every policy (the pool state is the hot set, so there is no -/// verify-only shortcut), mirroring Balancer. The planner stays policy-aware in -/// shape (the policy is threaded into the report) so later slices can refine -/// `HotSlotsOnly`/`Lazy`. +/// The flow runs for every policy (the pool state is the hot set). The planner +/// stays policy-aware in shape (the policy is threaded into the report) so later +/// slices can refine `HotSlotsOnly`/`Lazy`. struct CurveColdStartPlanner { pool: Address, /// Config-supplied coins, preserved across the run and re-emitted on `Ready`. @@ -518,9 +564,14 @@ struct CurveColdStartPlanner { /// Config-supplied Curve dialect; drives the discover `get_dy` ABI and is /// re-emitted on `Ready` so reactive + later sims keep it. variant: CurveVariant, + /// Config-supplied optional runtime bytecode seed, preserved across the run + /// and re-emitted on `Ready` (seeding itself is handled by the registry + /// before the driver runs; the planner only carries it through `finish`). + code_seed: Option, policy: ColdStartPolicy, phase: CurvePhase, - /// The pool slots discovered in round 1 and verified in round 2. + /// The pool slots being warmed: discovered in round 1 (discover→verify) or + /// pre-populated from the known read-set (verify-only), then verified. verified_slots: Vec<(Address, U256)>, /// Slots injected across the run (the refreshed read-set). changed_slots: Vec, @@ -534,15 +585,34 @@ impl CurveColdStartPlanner { pool: Address, coins: Vec
, variant: CurveVariant, + known_slots: Vec, + code_seed: Option, policy: ColdStartPolicy, ) -> Self { + // A pre-populated read-set selects the verify-only fast path: start in + // `Verify` with the known slots as the read-set, so `initial_plan` emits + // a single verify round (no discover call) and `finish` persists them. + // Sort + dedup for a stable, minimal fetch set. An empty read-set keeps + // the discover→verify default (byte-for-byte unchanged from before). + let mut slots = known_slots; + slots.sort_unstable(); + slots.dedup(); + let (phase, verified_slots) = if slots.is_empty() { + (CurvePhase::Discover, Vec::new()) + } else { + ( + CurvePhase::Verify, + slots.into_iter().map(|slot| (pool, slot)).collect(), + ) + }; Self { pool, coins, variant, + code_seed, policy, - phase: CurvePhase::Discover, - verified_slots: Vec::new(), + phase, + verified_slots, changed_slots: Vec::new(), repair: None, } @@ -551,6 +621,19 @@ impl CurveColdStartPlanner { impl AdapterColdStartPlanner for CurveColdStartPlanner { fn initial_plan(&mut self, _state: &dyn StateView) -> ColdStartPlan { + // Verify-only fast path: the read-set is already known (pre-populated in + // `new`), so skip discovery — and the pool-account/bytecode fetch and + // cold-cache `get_dy` faulting it needs — and warm exactly those slots in + // a single verify round. `on_results` lands directly in its `Verify` + // branch, and `finish` persists the same set. A stale/incomplete read-set + // is safe: the first `simulate_swap` lazily faults anything missing. + if matches!(self.phase, CurvePhase::Verify) { + return ColdStartPlan { + verify: self.verified_slots.clone(), + ..Default::default() + }; + } + // Round 1: ensure the pool's code, then run `get_dy(0, 1, DISCOVER_DX)` // and capture the slots it touches (restricted to the pool so only its // own read-set is collected — plain pools are self-contained). The @@ -720,6 +803,8 @@ impl AdapterColdStartPlanner for CurveColdStartPlanner { // Persist the config-supplied variant so the reactive path // and later sims keep the correct `get_dy` / event ABI. variant: self.variant, + // Preserve any caller-supplied bytecode seed across the run. + code_seed: self.code_seed.clone(), }); pool.status = PoolStatus::Ready; report.status = PoolStatus::Ready; @@ -908,4 +993,52 @@ mod tests { // NG uses its own AddLiquidity shape, not v2's. assert!(!ng.contains(&crypto_add_liquidity_topic(3))); } + + // `code_seeds` surfaces the caller-supplied runtime bytecode as a single + // verifiable seed when `CurveMetadata.code_seed` is set, and is a clean + // no-op (never an error) otherwise. Curve has no embedded/renderable + // template, so this hook is the only Curve seed source. + #[test] + fn code_seeds_returns_caller_supplied_bytecode_or_empty() { + use crate::adapters::PoolKey; + + let pool = Address::repeat_byte(0xcc); + // A tiny valid runtime (PUSH1 0, PUSH1 0, RETURN) stands in for a pool's + // real Vyper code — `code_seeds` does not execute it, only wraps it. + let runtime = Bytes::from_static(&[0x60, 0x00, 0x60, 0x00, 0xf3]); + let adapter = CurveAdapter::default(); + + // With a code_seed: exactly one seed, addressed at the pool, hash over + // the seeded bytes (== AdapterCodeSeed::new). + let seeded = PoolRegistration::new(PoolKey::Curve(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::Curve( + CurveMetadata::default() + .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"); + assert_eq!(seeds, vec![AdapterCodeSeed::new(pool, runtime)]); + + // No code_seed (the default): no seeds, not an error. + let unseeded = PoolRegistration::new(PoolKey::Curve(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::Curve(CurveMetadata::default())); + assert!( + adapter + .code_seeds(&unseeded) + .expect("never errors") + .is_empty(), + "no code_seed => no seeds" + ); + + // An empty code_seed is treated as absent (nothing to verify). + let empty = PoolRegistration::new(PoolKey::Curve(pool)).with_metadata( + ProtocolMetadata::Curve(CurveMetadata::default().with_code_seed(Bytes::new())), + ); + assert!( + adapter.code_seeds(&empty).expect("never errors").is_empty(), + "empty code_seed => no seeds" + ); + } } diff --git a/src/adapters/types.rs b/src/adapters/types.rs index fee1555..86799e8 100644 --- a/src/adapters/types.rs +++ b/src/adapters/types.rs @@ -2,7 +2,7 @@ use std::any::Any; use std::fmt; use std::sync::Arc; -use alloy_primitives::{Address, B256, U256}; +use alloy_primitives::{Address, B256, Bytes, U256}; use super::cache::{SlotChange, StateDiff, StateUpdate}; use super::storage::{SolidlyStorageLayout, V3StorageLayout}; @@ -608,18 +608,42 @@ pub enum CurveVariant { /// keeping cached state fresh for a later `simulate_swap`. Slot-only; all live /// on the pool address. Empty until cold-start runs. /// +/// **Pre-populating `discovered_slots`** (from a prior discovery, a block trace, +/// or a MetaRegistry-backed source) turns the otherwise unavoidable +/// discover→verify cold start into a single verify round: `cold_start` skips the +/// local `get_dy` discovery entirely, and the pool becomes eligible for the fast +/// bundled [`cold_start_many`](super::AdapterRegistry::cold_start_many) / +/// [`storage_sync`](super::storage_sync) path — the same one-shot hydration +/// Uniswap V2/V3 use. A stale/incomplete set is safe: verify refreshes what it +/// has and the first `simulate_swap` lazily faults any missing slot. +/// /// `variant` selects the index ABI (`StableSwap`/NG use `int128`; `CryptoSwap` /// uses `uint256`). Defaults to `StableSwap` (slice-1 + NG behavior). +/// +/// `code_seed` is an **optional** caller-supplied canonical runtime bytecode for +/// the pool. Curve pools are per-pool Vyper builds with no shared template +/// (unlike Uniswap V2's shared pair runtime or V3's rendered template), so the +/// crate embeds no Curve seed — but a caller that already knows a pool's runtime +/// can attach it here. Cold-start verifies it once against the on-chain +/// `EXTCODEHASH` (a mismatch is purged, falling back to lazily fetching the real +/// code — never a correctness risk), removing the one lazy code fetch a Curve +/// pool otherwise pays on its first `simulate_swap`. Empty/`None` = lazy fetch. #[non_exhaustive] #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CurveMetadata { /// The pool's static coin ordering (drives the `get_dy` token→index map). pub coins: Vec
, /// The `get_dy` read-set discovered at cold-start (balances + A + fee), - /// re-verified by the reactive path. Empty until discovery runs. + /// re-verified by the reactive path. Empty until discovery runs. Pre-fill it + /// to skip discovery (a verify-only cold start) and enable the fast bundled + /// hydration path. pub discovered_slots: Vec, /// The pool dialect selecting the `get_dy` / `TokenExchange` index ABI. pub variant: CurveVariant, + /// Optional caller-supplied canonical runtime bytecode for the pool, seeded + /// and verified once against on-chain code at cold-start. `None` (the + /// default) lazily fetches the real code on first simulate. + pub code_seed: Option, } impl CurveMetadata { @@ -643,6 +667,16 @@ impl CurveMetadata { self.variant = variant; self } + + /// Attach an optional canonical runtime bytecode seed for the pool. + /// + /// Cold-start verifies it once against the on-chain `EXTCODEHASH`; a mismatch + /// is purged and the pool falls back to lazily fetching its real code, so a + /// wrong seed is a latency question, never a correctness one. + pub fn with_code_seed(mut self, code_seed: impl Into) -> Self { + self.code_seed = Some(code_seed.into()); + self + } } /// Lifecycle status for a tracked pool registration. diff --git a/tests/bootstrap_many.rs b/tests/bootstrap_many.rs index 4f27cfe..216b667 100644 --- a/tests/bootstrap_many.rs +++ b/tests/bootstrap_many.rs @@ -273,4 +273,20 @@ fn supports_one_shot_hydration_classifies_by_protocol_and_metadata() { !supports_one_shot_hydration(&curve), "Curve has no persisted flat read-set until discovery runs" ); + + // ...but once a Curve pool's read-set is persisted (a prior discovery, a + // trace, or a registry), it joins the fast bundled hydration path — the + // discovered slots become a flat storage-sync program, just like V2/Solidly. + let curve_ready = PoolRegistration::new(PoolKey::Curve(Address::repeat_byte(0x05))) + .with_state_address(Address::repeat_byte(0x05)) + .with_metadata(ProtocolMetadata::Curve( + CurveMetadata::default() + .with_coins([Address::repeat_byte(0x0a), Address::repeat_byte(0x0b)]) + .with_discovered_slots([U256::from(1), U256::from(2)]) + .with_variant(CurveVariant::CryptoSwap), + )); + assert!( + supports_one_shot_hydration(&curve_ready), + "Curve with a persisted discovered read-set supports one-shot flat hydration" + ); } diff --git a/tests/cold_start_adoption.rs b/tests/cold_start_adoption.rs index f919fa5..e03fb5b 100644 --- a/tests/cold_start_adoption.rs +++ b/tests/cold_start_adoption.rs @@ -1552,6 +1552,118 @@ async fn curve_cold_start_discover_verify_ready() -> Result<()> { Ok(()) } +// Verify-only fast path: when the read-set is already known (discovered_slots +// pre-populated), cold-start skips discovery entirely. No pool runtime is +// installed here, so a discover `get_dy` sim would fail — reaching Ready proves +// no discovery ran. The single verify round warms the known slot, and coins / +// variant / the discovered set are preserved. +#[tokio::test(flavor = "multi_thread")] +async fn curve_cold_start_verify_only_skips_discovery() -> Result<()> { + let pool = Address::repeat_byte(0xc4); + let dai = Address::repeat_byte(0x01); + let usdc = Address::repeat_byte(0x02); + let stale = U256::from(1_u64); + let fresh = U256::from(777_000_u64); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + // Install a BARE, code-less pool account (no runtime). Two consequences: + // (1) the verified slot can be injected fully offline (the account exists, + // so no lazy account fetch), and (2) it doubles as the discovery-skip + // proof — a discover round would run `get_dy` against a code-less + // account, capture no slots, and repair (NoSlotsDiscovered), so reaching + // Ready proves the verify-only path skipped discovery entirely. + install_default_account(&mut cache, pool); + // Seed the known slot STALE; the single verify round must refresh it. + 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 registry = curve_registry(); + let mut registration = PoolRegistration::new(PoolKey::Curve(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::Curve( + CurveMetadata::default() + .with_coins(vec![dai, usdc]) + // A pre-populated read-set selects the verify-only fast path. + .with_discovered_slots(vec![U256::ZERO]) + .with_variant(CurveVariant::CryptoSwap), + )); + + let outcome = registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + + assert!( + matches!(outcome, ColdStartOutcome::Ready(_)), + "verify-only cold-start should reach Ready without discovery, got {outcome:?}" + ); + assert_eq!(registration.status, PoolStatus::Ready); + match registration.metadata { + ProtocolMetadata::Curve(ref m) => { + assert_eq!(m.coins, vec![dai, usdc], "config coins must be preserved"); + assert_eq!( + m.variant, + CurveVariant::CryptoSwap, + "config variant must be preserved" + ); + assert!( + m.discovered_slots.contains(&U256::ZERO), + "the known read-set must be persisted, got {:?}", + m.discovered_slots + ); + } + ref other => panic!("expected Curve metadata, got {other:?}"), + } + assert_eq!( + cache.cached_storage_value(pool, U256::ZERO), + Some(fresh), + "the single verify round must refresh the known slot to the fresh value" + ); + assert!( + asserter.read_q().is_empty(), + "the verify-only cold start must be fully offline (no RPC)" + ); + Ok(()) +} + +// Verify-only fast path, archive miss: a known slot that fails in the verify +// round must NOT be marked Ready over an unwarmed read-set — it needs a +// `VerifySlots` repair over the known slots, mirroring the discovery path's +// archive-miss behavior. +#[tokio::test(flavor = "multi_thread")] +async fn curve_cold_start_verify_only_unfetchable_slot_needs_repair() -> Result<()> { + let pool = Address::repeat_byte(0xc5); + + let (mut cache, _asserter) = setup_cache_with_asserter().await?; + cache.set_storage_batch_fetcher(fetcher_with_failures( + HashMap::new(), + vec![(pool, U256::ZERO)], + )); + + let registry = curve_registry(); + 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_discovered_slots(vec![U256::ZERO]) + .with_variant(CurveVariant::StableSwap), + )); + + let outcome = registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + assert!( + matches!( + outcome, + ColdStartOutcome::NeedsRepair(_, RepairAction::VerifySlots(_)) + ), + "an unfetchable known slot must need a VerifySlots repair, got {outcome:?}" + ); + assert_ne!(registration.status, PoolStatus::Ready); + 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")] From f95d0151523b44b242129d5a9fca7597594df52b Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 14:30:07 +0100 Subject: [PATCH 2/3] perf(balancer): verify-only cold-start when the vault read-set is known Ports the Curve verify-only fast path to BalancerV2ColdStartPlanner: when BalancerV2Metadata.balance_slots is already known (a prior discovery or a trace), skip the getPoolTokens discovery -- no vault-account fetch, no cold-cache faulting -- and warm exactly those slots in a single verify round, matching the bundled cold_start_many storage-program path. Config-supplied tokens are preserved (no getPoolTokens decode repopulates them). The discover->verify path is unchanged when balance_slots is empty. Adds balancer_cold_start_verify_only_skips_discovery. 217 tests pass; clippy/all-features/doc clean. Co-Authored-By: Claude Opus 4.8 --- src/adapters/balancer_v2.rs | 88 +++++++++++++++++++++++++++++------- tests/cold_start_adoption.rs | 81 +++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 16 deletions(-) diff --git a/src/adapters/balancer_v2.rs b/src/adapters/balancer_v2.rs index 459696f..88d9309 100644 --- a/src/adapters/balancer_v2.rs +++ b/src/adapters/balancer_v2.rs @@ -75,8 +75,19 @@ impl AmmAdapter for BalancerV2Adapter { )); }; + // A non-empty `balance_slots` means the vault read-set is already known (a + // prior discovery / trace), so the planner runs a verify-only fast path + // instead of rediscovering. `tokens` is preserved across a verify-only run + // (there is no `getPoolTokens` decode to repopulate it). + let (known_slots, tokens) = match &pool.metadata { + ProtocolMetadata::BalancerV2(metadata) => { + (metadata.balance_slots.clone(), metadata.tokens.clone()) + } + _ => (Vec::new(), Vec::new()), + }; + Ok(Box::new(BalancerV2ColdStartPlanner::new( - vault, pool_id, policy, + vault, pool_id, known_slots, tokens, policy, ))) } @@ -222,24 +233,31 @@ enum BalancerRepair { BalancesUnfetched, } -/// Cold-start planner for a Balancer V2 pool: a discover → verify access-list run. +/// Cold-start planner for a Balancer V2 pool, in one of two modes: /// -/// Balancer pool state lives in the vault behind a non-predictable storage layout, -/// so the planner cannot name the balance slots up front. Instead round 1 runs a -/// `getPoolTokens(poolId)` view-call on the vault (`restrict_to = [vault]`) and -/// captures the `(vault, slot)` pairs it SLOADs. Round 2 authoritatively verifies -/// exactly those discovered slots so the live balances are warmed. The token list -/// is decoded from the discover call's return data. +/// - **Discover → verify** (the balance read-set is unknown). Balancer pool state +/// lives in the vault behind a non-predictable storage layout, so the planner +/// cannot name the slots up front: round 1 runs a `getPoolTokens(poolId)` +/// view-call on the vault (`restrict_to = [vault]`), capturing the +/// `(vault, slot)` pairs it SLOADs and decoding the token list from the return +/// data; round 2 authoritatively verifies exactly those slots so the live +/// balances are warmed. +/// - **Verify-only** (the read-set is already known — `BalancerV2Metadata` +/// `balance_slots` pre-populated from a prior discovery or a trace). The planner +/// skips the `getPoolTokens` discovery — no vault-account fetch and no +/// cold-cache faulting — and warms exactly the known slots in a **single verify +/// round**, matching the bundled `cold_start_many` storage-program path. The +/// config-supplied `tokens` are preserved (there is no decode to repopulate). /// -/// The flow runs for every policy: the vault balances are the hot state, so there -/// is no verify-only shortcut. The planner stays policy-aware in shape (the policy -/// is threaded into the report) so later slices can refine `HotSlotsOnly`/`Lazy`. +/// The planner stays policy-aware in shape (the policy is threaded into the +/// report) so later slices can refine `HotSlotsOnly`/`Lazy`. struct BalancerV2ColdStartPlanner { vault: Address, pool_id: B256, policy: ColdStartPolicy, phase: BalancerPhase, - /// Tokens decoded from the `getPoolTokens` return data (round 1). + /// Tokens decoded from `getPoolTokens` (discover mode) or carried from the + /// config-supplied metadata (verify-only mode). tokens: Vec
, /// The vault balance slots discovered in round 1 and verified in round 2. verified_slots: Vec<(Address, U256)>, @@ -250,14 +268,39 @@ struct BalancerV2ColdStartPlanner { } impl BalancerV2ColdStartPlanner { - fn new(vault: Address, pool_id: B256, policy: ColdStartPolicy) -> Self { + fn new( + vault: Address, + pool_id: B256, + known_slots: Vec, + tokens: Vec
, + policy: ColdStartPolicy, + ) -> Self { + // A pre-populated balance read-set selects the verify-only fast path: + // start in `Verify` with those slots, so `initial_plan` emits one verify + // round (no getPoolTokens discover) and `finish` persists them. Sort + + // dedup for a stable, minimal fetch set. An empty read-set keeps the + // discover->verify default (byte-for-byte unchanged from before). + let mut slots = known_slots; + slots.sort_unstable(); + slots.dedup(); + let (phase, verified_slots) = if slots.is_empty() { + (BalancerPhase::Discover, Vec::new()) + } else { + ( + BalancerPhase::Verify, + slots.into_iter().map(|slot| (vault, slot)).collect(), + ) + }; Self { vault, pool_id, policy, - phase: BalancerPhase::Discover, - tokens: Vec::new(), - verified_slots: Vec::new(), + phase, + // Discover mode overwrites this from the getPoolTokens decode; + // verify-only mode preserves the config-supplied tokens (they came + // from the prior discovery that produced the known read-set). + tokens, + verified_slots, changed_slots: Vec::new(), repair: None, } @@ -266,6 +309,19 @@ impl BalancerV2ColdStartPlanner { impl AdapterColdStartPlanner for BalancerV2ColdStartPlanner { fn initial_plan(&mut self, _state: &dyn StateView) -> ColdStartPlan { + // Verify-only fast path: the balance read-set is already known + // (pre-populated in `new`), so skip the `getPoolTokens` discovery — and + // the vault-account fetch + cold-cache faulting it needs — and warm + // exactly those slots in a single verify round. `on_results` lands + // directly in its `Verify` branch. A stale/incomplete set is safe: the + // first `simulate_swap` lazily faults anything missing. + if matches!(self.phase, BalancerPhase::Verify) { + return ColdStartPlan { + verify: self.verified_slots.clone(), + ..Default::default() + }; + } + // Round 1: ensure the vault's code, then run `getPoolTokens` and capture // the vault slots it touches (restricted to the vault so only its balance // storage is collected). diff --git a/tests/cold_start_adoption.rs b/tests/cold_start_adoption.rs index e03fb5b..7d0a2d1 100644 --- a/tests/cold_start_adoption.rs +++ b/tests/cold_start_adoption.rs @@ -998,6 +998,87 @@ async fn balancer_cold_start_discover_verify_ready() -> Result<()> { Ok(()) } +// Verify-only fast path (Balancer): when the balance read-set is already known +// (balance_slots pre-populated), cold-start skips the getPoolTokens discovery. +// No vault runtime is installed, so a discover call would fail — reaching Ready +// proves no discovery ran. The single verify round warms the known slot, and the +// config-supplied tokens are preserved (no getPoolTokens decode to repopulate). +#[tokio::test(flavor = "multi_thread")] +async fn balancer_cold_start_verify_only_skips_discovery() -> Result<()> { + let vault = Address::repeat_byte(0x32); + let mut pid = [0u8; 32]; + pid[..20].fill(0x11); + pid[20..].fill(0x22); + let pool_id = B256::from(pid); + let token0 = Address::repeat_byte(0xc0); + let token1 = Address::repeat_byte(0xc1); + let stale = U256::from(1_u64); + let fresh = U256::from(1000_u64); + + let (mut cache, asserter) = setup_cache_with_asserter().await?; + // Bare, code-less vault: the known slot injects offline (account exists), and + // a discover getPoolTokens would fail on code-less code — so reaching Ready + // proves the verify-only path skipped discovery. + install_default_account(&mut cache, vault); + cache + .db_mut() + .insert_account_storage(vault, U256::from(2), stale)?; + cache.set_storage_batch_fetcher(fetcher_with_failures( + HashMap::from([((vault, U256::from(2)), fresh)]), + Vec::new(), + )); + + let registry = balancer_registry(); + let mut registration = PoolRegistration::new(PoolKey::BalancerV2(pool_id)) + .with_state_address(vault) + .with_metadata(ProtocolMetadata::BalancerV2( + BalancerV2Metadata::default() + .with_vault(vault) + .with_tokens([token0, token1]) + // A pre-populated read-set selects the verify-only fast path. + .with_balance_slots([U256::from(2)]), + )); + + let outcome = registry.cold_start(&mut registration, &mut cache, ColdStartPolicy::Eager)?; + + assert!( + matches!(outcome, ColdStartOutcome::Ready(_)), + "verify-only cold-start should reach Ready without discovery, got {outcome:?}" + ); + assert_eq!(registration.status, PoolStatus::Ready); + match registration.metadata { + ProtocolMetadata::BalancerV2(ref m) => { + assert_eq!(m.vault, Some(vault)); + assert_eq!( + m.tokens, + vec![token0, token1], + "config tokens must be preserved (no getPoolTokens decode ran)" + ); + assert!( + m.balance_slots.contains(&U256::from(2)), + "the known read-set must be persisted, got {:?}", + m.balance_slots + ); + assert_eq!( + m.pool_address, + Some(Address::repeat_byte(0x11)), + "pool_address is still derived from the poolId" + ); + } + ref other => panic!("expected BalancerV2 metadata, got {other:?}"), + } + assert_eq!( + cache.cached_storage_value(vault, U256::from(2)), + Some(fresh), + "the single verify round must refresh the known slot to the fresh value" + ); + assert!( + asserter.read_q().is_empty(), + "the verify-only cold start must be fully offline (no RPC)" + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn balancer_cold_start_missing_vault_is_unsupported() -> Result<()> { let pool_id = B256::repeat_byte(0x33); From 172da9388e72dd00ee447a093531684a6907ac01 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 17:40:12 +0100 Subject: [PATCH 3/3] style: apply rustfmt to cold-start fast path --- examples/curve_cold_start_phases.rs | 12 ++++++++++-- src/adapters/balancer_v2.rs | 6 +++++- src/adapters/curve.rs | 4 +++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/examples/curve_cold_start_phases.rs b/examples/curve_cold_start_phases.rs index 8bb7f2b..7dacf7d 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(()) @@ -276,7 +281,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/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.