Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 10 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
28 changes: 21 additions & 7 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,22 +119,36 @@ 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;
- **`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:
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=<archive-url> cargo run --release --example curve_cold_start_phases
E2E_RPC_URL=<archive-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
Expand Down
8 changes: 8 additions & 0 deletions docs/curve-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
40 changes: 37 additions & 3 deletions examples/curve_cold_start_phases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand All @@ -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),
);
Expand Down Expand Up @@ -276,7 +307,10 @@ async fn discover_once(provider: SharedProvider, block: BlockId) -> Result<Vec<U
})
}

fn curve_registration(discovered_slots: Vec<U256>, code_seed: Option<alloy_primitives::Bytes>) -> PoolRegistration {
fn curve_registration(
discovered_slots: Vec<U256>,
code_seed: Option<alloy_primitives::Bytes>,
) -> PoolRegistration {
let mut metadata = CurveMetadata::default()
.with_coins(vec![USDT, WBTC, WETH])
.with_discovered_slots(discovered_slots)
Expand Down
6 changes: 5 additions & 1 deletion src/adapters/balancer_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)))
}

Expand Down
111 changes: 110 additions & 1 deletion src/adapters/cold_start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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)?);
Expand All @@ -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<ColdStartOutcome, ColdStartError> {
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<ColdStartCall> = 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
Expand Down
4 changes: 3 additions & 1 deletion src/adapters/curve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions src/adapters/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -26,6 +33,7 @@ impl Default for AdapterRegistry {
adapters: HashMap::new(),
pools: HashMap::new(),
code_seeding: true,
access_list_discovery: true,
}
}
}
Expand All @@ -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> {
Expand Down
Loading
Loading