From fd69a11fa7311b7cc278615bb2119c6bea2f3475 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 11:59:57 +0100 Subject: [PATCH 1/9] hardening(tier-0): correctness & API-freeze fixes before v0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-release Tier 0 of the pre-launch checklist (correctness + API freeze): - PancakeSwap V3 Swap routing: Pancake's Swap appends two uint128 fields, so its topic0 differs. `event_sources` subscribes the Pancake topic for PancakeV3 pools and `decode_event` validates against the matching ABI (shared body decode of words 2/3/4). Previously a Pancake pool's swaps never routed. The pancake reactive test now uses the real topic + 9-field body. - Slipstream fee: tickSpacing-keyed discovery has no on-chain fee mapping, so discovered registrations leave `V3Metadata.fee` UNSET (was a bogus Some(0)); `simulate_swap` surfaces `MissingMetadata("V3 fee")` instead of quoting at fee 0 (Slipstream is discovery-only for quoting). - V3-family one-shot full-sync spec: use canonical `V3SyncSpec::uniswap` only for genuine Uniswap V3, and `V3SyncSpec::core` (slot0 + liquidity + ticks) for Pancake/Slipstream, whose fee-growth/observation slot positions are unverified — no longer injects Uniswap's aux slots into forks. - cold_start_many finalization: the fast path warms the cache and marks Ready WITHOUT running the planner's finish(); it is now gated on `fast_metadata_complete()` so metadata-incomplete registrations (e.g. a bare V2 pool missing token0/token1) fall back to the multi-round cold_start that decodes + merges them. - Panic fix: a V3 layout with non-positive tick_spacing no longer reaches full_word_range/v3_word_position (which assert > 0) via the fast path — `v3_sync_spec` returns None and the pool falls back. - Batch robustness: a malformed log for a watched topic no longer aborts the batch. `AmmReactiveHandler::handle` emits a NoStateEffect + `amm.decode_error` hook; `AdapterDriver::apply_logs` isolates `DriverError::Decode` and continues. - API freeze: `#[non_exhaustive]` on V3StorageLayout and SolidlyStorageLayout. Tests +6 (cold_start spec-selection x3, driver isolation, reactive batch isolation, cold_start_many fallback-merge) plus the Pancake rewrite and a Slipstream fee=None assertion. Full gate green: all-features / no-default / 7-way isolation clippy -D warnings, fmt, doc -D warnings, experimental x2, MSRV 1.88. Co-Authored-By: Claude Opus 4.8 --- src/adapters/cold_start.rs | 172 +++++++++++++++++++++++++++++++++---- src/adapters/driver.rs | 18 +++- src/adapters/factory.rs | 20 ++++- src/adapters/reactive.rs | 21 ++++- src/adapters/storage.rs | 10 +++ src/adapters/uniswap_v3.rs | 67 +++++++++++++-- tests/adapter_a1.rs | 48 +++++++++++ tests/adapter_reactive.rs | 75 +++++++++++++++- tests/bootstrap_many.rs | 62 ++++++++++++- tests/discovery_cl.rs | 6 ++ 10 files changed, 465 insertions(+), 34 deletions(-) diff --git a/src/adapters/cold_start.rs b/src/adapters/cold_start.rs index e659d99..f53ec1c 100644 --- a/src/adapters/cold_start.rs +++ b/src/adapters/cold_start.rs @@ -557,10 +557,10 @@ fn hydration_kind(pool: &PoolRegistration) -> Option { let address = pool.key.address()?; #[cfg(feature = "uniswap-v3")] - if let Some(layout) = v3_storage_layout(pool) { + if let Some(spec) = v3_sync_spec(pool) { return Some(HydrationKind::V3 { pool: address, - spec: V3SyncSpec::uniswap(layout), + spec, }); } // Without the `uniswap-v3` feature the V3 full-sync path is uncompiled, so @@ -573,20 +573,39 @@ fn hydration_kind(pool: &PoolRegistration) -> Option { .map(|spec| HydrationKind::Flat { spec }) } -/// Borrow the explicit V3 `storage_layout` for a V3-family pool, if present. +/// Build the one-shot V3 full-sync spec for a V3-family pool, if it is eligible. /// -/// Unlike [`layout_for`](super::storage::layout_for), this does **not** derive a -/// layout from `tick_spacing`: one-shot hydration is offered only when the -/// layout is explicitly carried, matching [`hydration_kind`]'s contract. +/// Eligibility (all required): +/// - the pool registered as a V3-family variant carrying an **explicit** +/// `storage_layout` (unlike [`layout_for`](super::storage::layout_for), this +/// does not derive a layout from `tick_spacing` alone), and +/// - that layout has a **positive** `tick_spacing` — `full_word_range` / +/// `v3_word_position` require it, so a non-positive spacing returns `None` +/// here and falls to the single-pool `cold_start` (which reports +/// `Unsupported`) rather than panicking in the fast path. +/// +/// The **canonical Uniswap** spec (which bakes in Uniswap's fee-growth, +/// protocol-fees, and observation slot positions) is used only for genuine +/// Uniswap V3 pools. Other V3-family forks (PancakeSwap V3, Slipstream) use the +/// layout-only [`V3SyncSpec::core`] (slot0 + liquidity + the ticks/bitmap the +/// layout locates) so hydration never injects auxiliary state from unverified +/// slot positions. Extend a fork to the full spec once its layout is confirmed. #[cfg(feature = "uniswap-v3")] -fn v3_storage_layout(pool: &PoolRegistration) -> Option { +fn v3_sync_spec(pool: &PoolRegistration) -> Option { use super::ProtocolMetadata; - match &pool.metadata { - ProtocolMetadata::UniswapV3(metadata) - | ProtocolMetadata::PancakeV3(metadata) - | ProtocolMetadata::Slipstream(metadata) => metadata.storage_layout, - _ => None, - } + let (metadata, canonical_uniswap) = match &pool.metadata { + ProtocolMetadata::UniswapV3(metadata) => (metadata, true), + ProtocolMetadata::PancakeV3(metadata) | ProtocolMetadata::Slipstream(metadata) => { + (metadata, false) + } + _ => return None, + }; + let layout = metadata.storage_layout.filter(|l| l.tick_spacing > 0)?; + Some(if canonical_uniswap { + V3SyncSpec::uniswap(layout) + } else { + V3SyncSpec::core(layout) + }) } /// Whether `pool` can be hydrated by a single one-shot storage program (the fast @@ -603,6 +622,50 @@ pub fn supports_one_shot_hydration(pool: &PoolRegistration) -> bool { hydration_kind(pool).is_some() } +/// Whether `pool`'s registration metadata is already complete enough for the +/// one-shot fast path to finalize it `Ready` *without* the adapter planner's +/// [`finish`](AdapterColdStartPlanner::finish) (metadata merge + status +/// validation). +/// +/// The fast path only warms the cache; a registration still missing identity +/// metadata — e.g. a Uniswap V2 pool without its `token0`/`token1`, which the +/// normal cold-start decodes from storage and merges — must fall back to the +/// multi-round [`cold_start`](AdapterRegistry::cold_start) so `finish` runs. +/// Registrations produced by factory discovery are already complete and stay on +/// the fast path. +/// +/// For a V3-family pool `finish` *preserves* (never merges) metadata, so +/// completeness here means the fields a later `simulate_swap` needs (`fee`) plus +/// the layout the fast path already requires. A V3 fork with no fee tier (e.g. a +/// discovered Slipstream pool, whose `fee` is deliberately unset) therefore +/// forgoes the fast path and takes the normal `cold_start` — acceptable, since it +/// is discovery-only for quoting anyway. Balancer/Curve flat hydration only +/// applies once a discovered read-set exists, which itself is produced by a +/// prior discover→verify `cold_start` that already ran `finish`. +fn fast_metadata_complete(pool: &PoolRegistration) -> bool { + use super::ProtocolMetadata; + match &pool.metadata { + ProtocolMetadata::UniswapV2(m) => m.token0.is_some() && m.token1.is_some(), + ProtocolMetadata::UniswapV3(m) + | ProtocolMetadata::PancakeV3(m) + | ProtocolMetadata::Slipstream(m) => m.fee.is_some() && m.storage_layout.is_some(), + // Solidly `finish` decodes+merges token0/token1 like V2, so require them + // here too — otherwise the fast path would leave metadata tokens `None` + // while the fallback populates them (an inconsistency for consumers that + // read `PoolRegistration.metadata`). + ProtocolMetadata::SolidlyV2(m) => { + m.token0.is_some() + && m.token1.is_some() + && m.stable.is_some() + && m.storage_layout.is_some() + } + ProtocolMetadata::BalancerV2(m) => m.vault.is_some() && !m.balance_slots.is_empty(), + ProtocolMetadata::Curve(m) => !m.coins.is_empty() && !m.discovered_slots.is_empty(), + // No known completeness contract → let the normal cold_start finalize it. + ProtocolMetadata::Unknown | ProtocolMetadata::Custom(_) => false, + } +} + /// A failure hydrating one pool from a one-shot storage program's output. /// /// Used only to decide per-pool fallback inside @@ -705,8 +768,13 @@ impl AdapterRegistry { /// this path collapses the fast-eligible pools into a fixed number of /// phases: /// - /// 1. **Classify.** Each pool is `fast` when its adapter is registered and - /// [`supports_one_shot_hydration`] holds; everything else is `fallback`. + /// 1. **Classify.** A pool is `fast` when its adapter is registered, + /// [`supports_one_shot_hydration`] holds, **and** its registration is + /// already metadata-complete for its protocol (the fast path warms the + /// cache and marks `Ready` without running the planner's `finish`, so a + /// pool whose identity metadata still needs decoding/merging — e.g. a + /// bare Uniswap V2 registration without `token0`/`token1` — is left to + /// `fallback`). Everything else is `fallback`. /// 2. **Batched seed + verify.** Every fast pool's [`code_seeds`] are seeded /// together (same skip rules as the single-pool path) and, if any are /// pending, verified in **one** account-fields call — unverifiable seeds @@ -752,7 +820,14 @@ impl AdapterRegistry { if self.adapter(pool.protocol()).is_none() { continue; } - if let Some(kind) = hydration_kind(pool) { + // Fast-path only registrations that are already metadata-complete for + // their protocol. The fast path warms the cache and finalizes `Ready` + // WITHOUT running the adapter planner's `finish()` (metadata merge + + // status validation), so a pool still missing identity metadata must + // take the normal multi-round `cold_start` to be finalized correctly. + if let Some(kind) = hydration_kind(pool) + && fast_metadata_complete(pool) + { is_fallback[index] = false; fast.push((index, kind)); } @@ -933,3 +1008,68 @@ fn verify_pending_seeds(cache: &mut EvmCache) -> CodeSeedReport { } } } + +#[cfg(all(test, feature = "uniswap-v3"))] +mod tests { + use super::*; + use crate::adapters::storage::V3StorageLayout; + use crate::adapters::types::{PoolKey, PoolRegistration, ProtocolMetadata, V3Metadata}; + use crate::adapters::v3_sync::V3SyncSpec; + use alloy_primitives::Address; + + /// A genuine Uniswap V3 pool takes the canonical full spec (fee-growth, + /// protocol-fees, and observation slots baked in). + #[test] + fn uniswap_v3_uses_the_canonical_full_spec() { + let layout = V3StorageLayout::uniswap(10); + let pool = PoolRegistration::new(PoolKey::UniswapV3(Address::repeat_byte(0x11))) + .with_metadata(ProtocolMetadata::UniswapV3( + V3Metadata::default() + .with_fee(500) + .with_storage_layout(layout), + )); + assert_eq!(v3_sync_spec(&pool), Some(V3SyncSpec::uniswap(layout))); + } + + /// PancakeSwap V3 and Slipstream use the layout-only `core` spec (slot0 + + /// liquidity + ticks) — their extra static/observation slots are not verified, + /// so hydration must not inject Uniswap's positions for them. + #[test] + fn pancake_and_slipstream_use_the_core_spec_until_verified() { + let pancake_layout = V3StorageLayout::pancake(10); + let pancake = PoolRegistration::new(PoolKey::PancakeV3(Address::repeat_byte(0x22))) + .with_metadata(ProtocolMetadata::PancakeV3( + V3Metadata::default() + .with_fee(2500) + .with_storage_layout(pancake_layout), + )); + assert_eq!( + v3_sync_spec(&pancake), + Some(V3SyncSpec::core(pancake_layout)) + ); + + let slip_layout = V3StorageLayout::slipstream(100); + let slip = PoolRegistration::new(PoolKey::Slipstream(Address::repeat_byte(0x33))) + .with_metadata(ProtocolMetadata::Slipstream( + V3Metadata::default().with_storage_layout(slip_layout), + )); + assert_eq!(v3_sync_spec(&slip), Some(V3SyncSpec::core(slip_layout))); + } + + /// A non-positive tick spacing would panic in `full_word_range` / + /// `v3_word_position`; `v3_sync_spec` must return `None` so the pool falls + /// back to the single-pool `cold_start` (Unsupported) instead of panicking in + /// the `cold_start_many` fast path. + #[test] + fn non_positive_tick_spacing_is_not_fast_eligible() { + let layout = V3StorageLayout::uniswap(0); + let pool = PoolRegistration::new(PoolKey::UniswapV3(Address::repeat_byte(0x44))) + .with_metadata(ProtocolMetadata::UniswapV3( + V3Metadata::default() + .with_fee(500) + .with_storage_layout(layout), + )); + assert_eq!(v3_sync_spec(&pool), None); + assert!(!supports_one_shot_hydration(&pool)); + } +} diff --git a/src/adapters/driver.rs b/src/adapters/driver.rs index faf1db0..008c036 100644 --- a/src/adapters/driver.rs +++ b/src/adapters/driver.rs @@ -75,6 +75,17 @@ impl AdapterDriver { self.apply_routed_log(cache, pool, log) } + /// Apply a batch of logs in order, returning a report per routed-and-decoded + /// log. + /// + /// Batch-robust: a single malformed / undecodable log (a + /// [`DriverError::Decode`]) is **skipped** so the rest of the batch still + /// applies — the same contract the reactive runtime path + /// ([`AmmReactiveHandler`](super::AmmReactiveHandler)) follows. A + /// [`DriverError::NoAdapter`] is a registry misconfiguration rather than + /// per-log data, so it still propagates and aborts the batch. Use + /// [`apply_log`](Self::apply_log) when a caller needs the structured decode + /// error for an individual log. pub fn apply_logs( &self, cache: &mut C, @@ -85,8 +96,11 @@ impl AdapterDriver { { let mut reports = Vec::new(); for log in logs { - if let Some(report) = self.apply_log(cache, log)? { - reports.push(report); + match self.apply_log(cache, log) { + Ok(Some(report)) => reports.push(report), + Ok(None) => {} + Err(DriverError::Decode { .. }) => {} + Err(err @ DriverError::NoAdapter(_)) => return Err(err), } } Ok(reports) diff --git a/src/adapters/factory.rs b/src/adapters/factory.rs index 5d5ac26..fd1f747 100644 --- a/src/adapters/factory.rs +++ b/src/adapters/factory.rs @@ -711,8 +711,12 @@ impl ClFactorySpec { } /// A tickSpacing-keyed CL fork (Slipstream shape): `getPool[t0][t1][spacing]` - /// only — no `feeAmountTickSpacing` read. Fee defaults to `Fixed(0)`; set a - /// real [`fee_source`](Self::with_fee_source) if the fork exposes one. + /// only — no `feeAmountTickSpacing` read. Fee defaults to `Fixed(0)`, the + /// "no fee mapping" sentinel: discovered registrations leave `V3Metadata.fee` + /// **unset** (so `simulate_swap` returns `MissingMetadata("V3 fee")` rather + /// than quoting at fee 0 — these forks are discovery-only for quoting unless + /// the caller supplies a compatible quoter). Set a real + /// [`fee_source`](Self::with_fee_source) if the fork exposes one on-chain. pub fn tick_spacing_keyed( protocol: ProtocolId, factory: Address, @@ -1590,13 +1594,21 @@ impl ConcentratedLiquidityFactory { ProtocolId::Slipstream => V3StorageLayout::slipstream(tick_spacing), _ => V3StorageLayout::uniswap(tick_spacing), }; - let metadata = V3Metadata::default() + let mut metadata = V3Metadata::default() .with_token0(token0) .with_token1(token1) - .with_fee(fee) .with_tick_spacing(tick_spacing) .with_storage_layout(storage_layout) .with_factory(self.spec.factory); + // A resolved fee of 0 is the tickSpacing-keyed "no fee mapping" sentinel + // (Slipstream / Aerodrome CL have no on-chain fee→pool mapping and set + // `FeeSource::Fixed(0)`): leave `fee` UNSET rather than record a bogus 0, + // so `simulate_swap` surfaces `MissingMetadata("V3 fee")` — Slipstream is + // discovery-only for quoting — instead of silently quoting at fee 0. + // Fee-keyed forks always resolve a real, non-zero tier. + if fee != 0 { + metadata = metadata.with_fee(fee); + } let metadata = if let Some(quoter) = self.spec.quoter { metadata.with_quoter(quoter) } else { diff --git a/src/adapters/reactive.rs b/src/adapters/reactive.rs index 7c8a2a4..d0c31c8 100644 --- a/src/adapters/reactive.rs +++ b/src/adapters/reactive.rs @@ -87,9 +87,24 @@ impl ReactiveHandler for AmmReactiveHandler { let result = adapter.decode_event(pool, log, state); if let Some(error) = result.error { - return Err(HandlerError::new(format!( - "adapter decode error for {protocol:?}: {error:?}" - ))); + // A malformed / undecodable log for a watched topic must NOT abort + // the batch: other pools' events in the same `ingest_batch` still + // need to apply. Skip this log with a `NoStateEffect` outcome and + // surface the failure as an observability hook instead of a hard + // `HandlerError`. + let labels = vec![ + ReportTag::new("protocol", format!("{protocol:?}")), + ReportTag::new("emitter", format!("{:?}", log.address)), + ReportTag::new("error", format!("{error:?}")), + ]; + return Ok(HandlerOutcome { + effects: vec![ReactiveEffect::Hook(hook_signal( + "amm.decode_error", + labels.clone(), + ))], + quality: StateEffectQuality::NoStateEffect, + tags: labels, + }); } let Some(event) = result.event else { diff --git a/src/adapters/storage.rs b/src/adapters/storage.rs index d5ae6ac..153522a 100644 --- a/src/adapters/storage.rs +++ b/src/adapters/storage.rs @@ -47,6 +47,12 @@ pub const SLIPSTREAM_TICKS_BASE_SLOT: U256 = U256::from_limbs([19, 0, 0, 0]); pub const SLIPSTREAM_TICK_BITMAP_BASE_SLOT: U256 = U256::from_limbs([18, 0, 0, 0]); /// Storage layout for a V3-style concentrated-liquidity pool. +/// +/// `#[non_exhaustive]`: construct via [`V3StorageLayout::new`] (or the +/// `uniswap`/`pancake`/`slipstream` presets) so future layout fields (e.g. a new +/// fork's fee-growth or observation base slots) can be added without a breaking +/// change. +#[non_exhaustive] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct V3StorageLayout { pub slot0_slot: U256, @@ -114,6 +120,10 @@ impl V3StorageLayout { /// indices are fork-specific and config-supplied — there is no derivable default, /// so validate a fork's layout with the gated RPC-parity test before relying on /// it in production. +/// +/// `#[non_exhaustive]`: construct via [`SolidlyStorageLayout::new`] so future +/// layout fields can be added without a breaking change. +#[non_exhaustive] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct SolidlyStorageLayout { pub reserve0_slot: U256, diff --git a/src/adapters/uniswap_v3.rs b/src/adapters/uniswap_v3.rs index 359de36..f15de02 100644 --- a/src/adapters/uniswap_v3.rs +++ b/src/adapters/uniswap_v3.rs @@ -27,6 +27,22 @@ sol! { event Burn(address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1); } +/// PancakeSwap V3 `Swap` appends `protocolFeesToken0`/`protocolFeesToken1` +/// (`uint128`) to the Uniswap V3 event, so its `topic0` differs (`0x19b47279…` +/// vs Uniswap's `0xc42079f9…`). The extra fields append after `tick`, so +/// `sqrtPriceX96`/`liquidity`/`tick` stay at data words 2/3/4 and the body decode +/// is shared with the Uniswap [`Swap`]. `Mint`/`Burn` are unchanged from Uniswap +/// V3, so their hashes are shared. Wrapped in a module so the 9-field event's +/// `sol!`-generated constructor can be exempted from `clippy::too_many_arguments` +/// without relaxing the lint for the rest of the file. +mod pancake_v3 { + #![allow(clippy::too_many_arguments)] + alloy_sol_types::sol! { + event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick, uint128 protocolFeesToken0, uint128 protocolFeesToken1); + } +} +use pancake_v3::Swap as PancakeV3Swap; + const SLOT0_PRICE_TICK_BITS: usize = 184; const SLOT0_TICK_SHIFT: usize = 160; @@ -78,7 +94,7 @@ impl AmmAdapter for ConcentratedLiquidityAdapter { EventSource::direct( address, vec![ - Swap::SIGNATURE_HASH, + swap_topic_for(pool.protocol()), Mint::SIGNATURE_HASH, Burn::SIGNATURE_HASH, ], @@ -154,7 +170,9 @@ impl AmmAdapter for ConcentratedLiquidityAdapter { }; if topic0 == Swap::SIGNATURE_HASH { - self.decode_swap(pool, log) + self.decode_swap(pool, log, topic0, SwapAbi::Uniswap) + } else if topic0 == PancakeV3Swap::SIGNATURE_HASH { + self.decode_swap(pool, log, topic0, SwapAbi::Pancake) } else if topic0 == Mint::SIGNATURE_HASH { self.decode_tick_range_repair(pool, log, true) } else if topic0 == Burn::SIGNATURE_HASH { @@ -250,6 +268,26 @@ fn v3_warm_word_radius(pool: &PoolRegistration) -> Option { v3_metadata(pool).and_then(|m| m.warm_word_radius) } +/// Which `Swap` ABI a routed log matched — the Uniswap V3 shape or the +/// PancakeSwap V3 shape (two extra `uint128` fields, so a distinct `topic0`). +#[derive(Clone, Copy)] +enum SwapAbi { + Uniswap, + Pancake, +} + +/// The `Swap` event `topic0` to subscribe/route for `protocol`. +/// +/// PancakeSwap V3 emits an extended `Swap` (extra `protocolFeesToken0/1`), so its +/// `topic0` differs from Uniswap's; every other V3-family fork (Uniswap V3, +/// Slipstream) uses the canonical Uniswap `Swap` hash. +fn swap_topic_for(protocol: ProtocolId) -> B256 { + match protocol { + ProtocolId::PancakeV3 => PancakeV3Swap::SIGNATURE_HASH, + _ => Swap::SIGNATURE_HASH, + } +} + /// Borrow the [`V3Metadata`] for a pool if it registered as any V3-family /// variant (Uniswap V3 / Pancake V3 / Slipstream), else `None`. fn v3_metadata(pool: &PoolRegistration) -> Option<&V3Metadata> { @@ -262,8 +300,27 @@ fn v3_metadata(pool: &PoolRegistration) -> Option<&V3Metadata> { } impl ConcentratedLiquidityAdapter { - fn decode_swap(&self, pool: &PoolRegistration, log: &Log) -> AdapterEventResult { - if Swap::decode_log_data_validate(&log.data).is_err() { + fn decode_swap( + &self, + pool: &PoolRegistration, + log: &Log, + topic0: B256, + abi: SwapAbi, + ) -> AdapterEventResult { + // Validate against the ABI whose topic0 matched. Cross-protocol safety + // comes from topic0 routing — a pool only subscribes its own Swap hash + // (`swap_topic_for`), so `decode_event` always pairs the matched topic0 + // with its `SwapAbi` — NOT from payload length: alloy's log decoder reads + // only the leading static words, so the Uniswap validator tolerates the + // Pancake body's two trailing `uint128`s (the Pancake validator, which + // needs more words, does reject the shorter Uniswap body). Either way + // `sqrtPriceX96`/`liquidity`/`tick` share data words 2/3/4, so the body + // decode below is ABI-agnostic once the matched validator passes. + let valid = match abi { + SwapAbi::Uniswap => Swap::decode_log_data_validate(&log.data).is_ok(), + SwapAbi::Pancake => PancakeV3Swap::decode_log_data_validate(&log.data).is_ok(), + }; + if !valid { return AdapterEventResult::error(AdapterEventError::MalformedLog( "malformed V3 Swap log", )); @@ -302,7 +359,7 @@ impl ConcentratedLiquidityAdapter { AdapterEventResult::event(AdapterEvent { pool: pool.key.clone(), emitter: log.address, - topic0: Swap::SIGNATURE_HASH, + topic0, kind: AdapterEventKind::Swap, updates: vec![ StateUpdate::slot_masked(address, layout.slot0_slot, mask, value), diff --git a/tests/adapter_a1.rs b/tests/adapter_a1.rs index 0a5973e..bd0e0cb 100644 --- a/tests/adapter_a1.rs +++ b/tests/adapter_a1.rs @@ -395,6 +395,54 @@ fn driver_processes_logs_in_order_and_reports_post_apply_repairs() { )); } +/// `apply_logs` is batch-robust: a single malformed log (a `DriverError::Decode`) +/// is skipped so the rest of the batch still applies — the same contract the +/// reactive runtime path follows. A malformed Sync ahead of a valid one must not +/// drop the valid event or abort the batch. +#[test] +fn apply_logs_isolates_a_malformed_log_from_the_batch() { + let pool = Address::repeat_byte(0x9a); + let mask112 = (U256::from(1) << 112) - U256::from(1); + + let mut registry = AdapterRegistry::new(); + registry + .register_adapter(Arc::new(UniswapV2Adapter::default())) + .unwrap(); + registry + .register_pool( + PoolRegistration::new(PoolKey::UniswapV2(pool)) + .with_state_address(pool) + .with_event_source(EventSource::direct(pool, vec![v2_sync_topic()])), + ) + .unwrap(); + + let mut cache = MockCache::default(); + // Warm the reserves slot so the valid masked Sync write lands exactly. + cache.seed(pool, V2_RESERVES_SLOT, U256::ZERO); + + let driver = AdapterDriver::new(registry); + let reports = driver + .apply_logs( + &mut cache, + &[ + // Truncated one-word Sync body → DriverError::Decode, isolated. + log(pool, vec![v2_sync_topic()], word(U256::from(1_u64))), + // Well-formed Sync → applied. + log( + pool, + vec![v2_sync_topic()], + abi_words([U256::from(111_u64), U256::from(222_u64)]), + ), + ], + ) + .expect("a malformed log must not abort the batch"); + + assert_eq!(reports.len(), 1, "only the valid Sync yields a report"); + let raw = cache.value(pool, V2_RESERVES_SLOT).unwrap(); + assert_eq!(raw & mask112, U256::from(111_u64)); + assert_eq!((raw >> 112) & mask112, U256::from(222_u64)); +} + #[test] fn uniswap_v2_sync_updates_reserves_without_clobbering_timestamp() { let pool = Address::repeat_byte(0xbb); diff --git a/tests/adapter_reactive.rs b/tests/adapter_reactive.rs index 53ab36c..3726443 100644 --- a/tests/adapter_reactive.rs +++ b/tests/adapter_reactive.rs @@ -201,6 +201,12 @@ fn v3_swap_topic() -> B256 { keccak256("Swap(address,address,int256,int256,uint160,uint128,int24)") } +/// PancakeSwap V3 `Swap` — appends `protocolFeesToken0`/`protocolFeesToken1` +/// (`uint128`), giving it a distinct `topic0` from the Uniswap V3 `Swap`. +fn pancake_v3_swap_topic() -> B256 { + keccak256("Swap(address,address,int256,int256,uint160,uint128,int24,uint128,uint128)") +} + fn v3_mint_topic() -> B256 { keccak256("Mint(address,address,int24,int24,uint128,uint256,uint256)") } @@ -420,6 +426,54 @@ async fn v2_sync_applies_masked_update_through_reactive_runtime() -> Result<()> Ok(()) } +/// Batch robustness: a malformed log for a watched topic must NOT abort the +/// reactive batch — a later valid log in the same `ingest_batch` still applies. +/// The handler isolates the decode failure to a `NoStateEffect` outcome instead +/// of a `HandlerError` that would fail the whole batch. +#[tokio::test] +async fn malformed_log_does_not_abort_reactive_batch() -> Result<()> { + let pool = Address::repeat_byte(0x3f); + let reserve0 = U256::from(777_u64); + let reserve1 = U256::from(888_u64); + + // First: the correct Sync topic + emitter, but a truncated one-word body the + // V2 adapter rejects as malformed. Second: a well-formed Sync for the same + // pool. If the malformed log aborted the batch, the valid one would not land. + let malformed = rpc_log(pool, vec![v2_sync_topic()], abi_words([reserve0]), 12, 0, 0); + let valid = rpc_log( + pool, + vec![v2_sync_topic()], + abi_words([reserve0, reserve1]), + 12, + 0, + 1, + ); + + let mut cache = setup_cache().await?; + // Warm the reserves slot so the valid Sync's masked write lands exactly + // (a cold slot would be skipped into a resync rather than applied). + cache.apply_updates(&[StateUpdate::slot(pool, V2_RESERVES_SLOT, U256::ZERO)]); + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(v2_registry(pool, true))))?; + + // The whole batch must succeed (no HandlerError propagated by the malformed log). + runtime.ingest_batch( + &mut cache, + batch(vec![ + (ReactiveInput::Log(malformed), included_context(12, 0)), + (ReactiveInput::Log(valid), included_context(12, 1)), + ]), + )?; + + // The valid Sync's reserves landed despite the earlier malformed log. + let raw = cache + .cached_storage_value(pool, V2_RESERVES_SLOT) + .expect("valid Sync must have written the reserves slot"); + assert_eq!(raw & low_mask(112), reserve0); + assert_eq!((raw >> 112) & low_mask(112), reserve1); + Ok(()) +} + #[tokio::test] async fn v2_sync_cold_slot_emits_hash_pinned_resync_and_repairs_cache() -> Result<()> { let pool = Address::repeat_byte(0x32); @@ -1395,14 +1449,26 @@ async fn pancake_v3_routes_and_applies_through_family_adapter() -> Result<()> { let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); runtime.register_handler(Arc::new(AmmReactiveHandler::new(registry)))?; + // A PancakeSwap V3 `Swap`: the distinct Pancake topic0 and the extended + // 7-word non-indexed body (…, tick, protocolFeesToken0, protocolFeesToken1). + // Routing must accept the Pancake topic (which the Uniswap adapter would not + // have subscribed), and the shared body decode still reads words 2/3/4. let log = rpc_log( pool, vec![ - v3_swap_topic(), + pancake_v3_swap_topic(), topic_address(Address::repeat_byte(0x01)), topic_address(Address::repeat_byte(0x02)), ], - abi_words([U256::ZERO, U256::ZERO, sqrt_price, liquidity, U256::ZERO]), + abi_words([ + U256::ZERO, + U256::ZERO, + sqrt_price, + liquidity, + U256::ZERO, + U256::from(7_u64), + U256::from(9_u64), + ]), 50, 0, 0, @@ -1931,7 +1997,10 @@ async fn solidly_sync_without_layout_does_not_mutate_cache() -> Result<()> { // `discovered_slots` rather than applying the event payload. These tests cover: // (1) a cold-started pool emits a `VerifySlots` resync over its discovered slot, // and (2) the batch-robustness guard: an empty-`discovered_slots` pool must not -// error or mutate (a decode error would fail the whole `ingest_batch`). +// error or mutate — the adapter returns `ignored()` for the known-unsupported +// case (and, since the handler now isolates decode failures per-log, even a +// genuine decode error no longer fails the whole `ingest_batch`; see +// `malformed_log_does_not_abort_reactive_batch`). fn curve_token_exchange_topic() -> B256 { keccak256("TokenExchange(address,int128,uint256,int128,uint256)") diff --git a/tests/bootstrap_many.rs b/tests/bootstrap_many.rs index ab6b17b..4f27cfe 100644 --- a/tests/bootstrap_many.rs +++ b/tests/bootstrap_many.rs @@ -80,11 +80,17 @@ fn reserves(reserve0: u64, reserve1: u64) -> U256 { U256::from(reserve0) | (U256::from(reserve1) << 112) } +/// A metadata-complete Uniswap V2 registration (token0/token1 + fee): eligible +/// for the fast one-shot path (see `fast_metadata_complete`), so it exercises the +/// fast→fallback transition rather than being diverted to fallback by the gate. fn v2_registration(pool: Address) -> PoolRegistration { PoolRegistration::new(PoolKey::UniswapV2(pool)) .with_state_address(pool) .with_metadata(ProtocolMetadata::UniswapV2( - UniswapV2Metadata::default().with_fee_bps(30), + UniswapV2Metadata::default() + .with_token0(Address::repeat_byte(0xa0)) + .with_token1(Address::repeat_byte(0xa1)) + .with_fee_bps(30), )) } @@ -146,6 +152,60 @@ async fn cold_start_many_falls_back_to_ready_when_hydration_cannot_run() -> Resu Ok(()) } +/// The metadata-completeness gate: a registration still missing its identity +/// metadata (a Uniswap V2 pool with no `token0`/`token1`) must NOT be finalized +/// by the fast path — it falls back to the normal `cold_start`, whose planner +/// decodes and merges the tokens from storage. Without the gate the fast path +/// would mark it `Ready` with `token0`/`token1` still `None` (finish() skipped). +#[tokio::test(flavor = "multi_thread")] +async fn cold_start_many_incomplete_metadata_falls_back_and_merges_tokens() -> Result<()> { + let pool = Address::repeat_byte(0x55); + let t0 = Address::repeat_byte(0x66); + let t1 = Address::repeat_byte(0x77); + let provider = empty_mock_provider(); + let mut cache = EvmCache::new(provider.clone()).await; + + cache.set_storage_batch_fetcher(stub_fetcher(HashMap::from([ + ((pool, V2_TOKEN0_SLOT), token_word(t0)), + ((pool, V2_TOKEN1_SLOT), token_word(t1)), + ((pool, V2_RESERVES_SLOT), reserves(10, 20)), + ]))); + + let mut registry = AdapterRegistry::new(); + registry.register_adapter(Arc::new(UniswapV2Adapter::default()))?; + + // No token0/token1 in metadata → not fast-eligible → must fall back. + let mut pools = vec![ + PoolRegistration::new(PoolKey::UniswapV2(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::UniswapV2( + UniswapV2Metadata::default().with_fee_bps(30), + )), + ]; + let outcomes = registry + .cold_start_many( + &mut pools, + &mut cache, + provider.as_ref(), + ColdStartPolicy::Eager, + ) + .await?; + + assert!(matches!(outcomes[0], ColdStartOutcome::Ready(_))); + assert_eq!(pools[0].status, PoolStatus::Ready); + // The fallback cold_start decoded + merged the tokens from storage; the fast + // path (which skips finish()) would have left them None. + match &pools[0].metadata { + ProtocolMetadata::UniswapV2(m) => { + assert_eq!(m.token0, Some(t0), "fallback must merge decoded token0"); + assert_eq!(m.token1, Some(t1), "fallback must merge decoded token1"); + assert_eq!(m.fee_bps, Some(30), "config fee_bps must survive the merge"); + } + other => panic!("expected UniswapV2 metadata, got {other:?}"), + } + Ok(()) +} + /// An empty pool slice is a no-op that returns no outcomes (and touches nothing). #[tokio::test(flavor = "multi_thread")] async fn cold_start_many_empty_is_noop() -> Result<()> { diff --git a/tests/discovery_cl.rs b/tests/discovery_cl.rs index 98cead3..75ea075 100644 --- a/tests/discovery_cl.rs +++ b/tests/discovery_cl.rs @@ -216,6 +216,12 @@ fn tick_spacing_keyed_resolves_without_fee_mapping() -> Result<()> { Some(spacing), "tickSpacing is the key" ); + assert_eq!( + cl_metadata(&found[0]).fee, + None, + "no on-chain fee mapping → fee is left unset (not a bogus 0), so \ + simulate_swap surfaces MissingMetadata rather than quoting at fee 0" + ); assert_eq!(cache.batch_reads, 1); Ok(()) } From 4b12cca3799f52ef8db6d88950aef1976368e475 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 12:32:56 +0100 Subject: [PATCH 2/9] hardening(tier-1): doc-truth & offline-completeness before v0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second of the pre-release hardening PRs (P1 correctness + doc accuracy), stacked on tier-0. - SimError typed source: `SimError::Execution` now carries a boxed `dyn Error + Send + Sync` (was `String`) with a `source()` impl, matching the crate's other boxed-source facades (CacheError/DriverError). `quote_via_call` boxes the CacheError so a consumer can downcast the underlying cause. SimError drops Clone/PartialEq/Eq (boxed payload); affected test assertions moved to `matches!`, and facade_typing gains a downcast-source assertion. - V3 offline completeness: the windowed cold-start planner now warms ALL FOUR `Tick.Info` words of each initialized tick (was {0, 3}); a tick-crossing quote reads feeGrowthOutside{0,1}X128 (words 1/2) too, so {0,3} forced a mid-quote lazy fetch. The reactive Mint/Burn resync (repair.rs) refreshes all four to match (a tick flip changes feeGrowthOutside). Golden/oracle tests updated (7→11, 6→10 slots) and the cold-start test now asserts all four are warmed. - README doc-truth: * headline "purely from logs / no RPC / fully offline" narrowed to the real exact-write-vs-resync split; * "pool's own quote entrypoint" → "protocol's canonical quote entrypoint" (V2/V3 quote via Router/Quoter, not the pool); * cut the "Balancer/Curve seeding … in scope … before complete" roadmap leak (now: seeding covers V2/V3; others fetch code lazily — a latency-only diff); * added a Solidly offline caveat (getAmountOut also reads stable/decimals + an external factory.getFee()). - Factory-preset comment reconciliation: the gated RPC-test headers/messages claimed "placeholder … TODO(slice)" while the shipped presets carry the on-chain-confirmed constants (Pancake getPool slot 2 / feeAmountTickSpacing slot 1 + verify_derivations on + create2 pinned; Aerodrome getPool slot 5 + SOLIDLY_AERODROME_LAYOUT). Comments now describe the tests as confirming the shipped constants. Velodrome doc reworded (reuses Aerodrome's Base-verified constants; unverified for Optimism). Slipstream doc: discovery-only quoting (fee left unset → MissingMetadata). Full gate green: tests (all/default/no-default), clippy -D warnings (all-features + 7-way isolation + no-default), fmt, doc -D warnings, experimental x2, heavy-dep guard, MSRV 1.88. Co-Authored-By: Claude Opus 4.8 --- README.md | 34 ++++++++++++++++++++-------- src/adapters/factory.rs | 14 +++++++----- src/adapters/repair.rs | 31 +++++++++++++------------ src/adapters/sim.rs | 26 ++++++++++++++++----- src/adapters/uniswap_v3.rs | 18 ++++++++++----- tests/adapter_reactive.rs | 9 ++++---- tests/adapter_swap_sim.rs | 21 +++++++++-------- tests/cold_start_adoption.rs | 8 +++++++ tests/discovery_cl_rpc.rs | 41 ++++++++++++++++++---------------- tests/discovery_solidly_rpc.rs | 41 +++++++++++++++++----------------- tests/facade_typing.rs | 13 ++++++++++- 11 files changed, 162 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 7cfaa0a..661ab7c 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,20 @@ `evm-amm-state` is a real-time AMM state engine built on a forked-EVM state cache ([`evm-fork-cache`]). It tracks a working set of pools, **cold-starts** -their on-chain state into the cache, keeps them current **purely from chain log -events** (no RPC in the hot path), and runs fast, **fully-offline swap -simulations** against the live-synced state. +their on-chain state into the cache, and keeps them current **from chain log +events**: protocols whose events carry absolute state (Uniswap V2 / Solidly +`Sync`) are updated with **no RPC at all**, while protocols whose events carry +only deltas (Uniswap V3 liquidity, Balancer, Curve) turn each event into a +bounded, hash-pinned storage **resync** (block trace first, then bulk-storage / +point-read fallback). Once a pool's quote read-set is warmed and current, swap +**simulations run fully offline** against the live-synced state. The defining design choice: **no reimplemented AMM math.** Every quote runs the -pool's *own* canonical on-chain quote entrypoint inside a local revm against the -warmed cache (e.g. Uniswap `QuoterV2`, Curve `get_dy`), then decodes the result. -There is no `LocalAMM`/`amm-math` formula layer to drift from the real contracts. +protocol's *canonical* on-chain quote entrypoint inside a local revm against the +warmed cache — the pool's own `get_dy` / `getAmountOut`, or the protocol's +official router/quoter (Uniswap `QuoterV2` / `Router02`) — then decodes the +result. There is no `LocalAMM`/`amm-math` formula layer to drift from the real +contracts. [`evm-fork-cache`]: https://github.com/KaiCode2/evm-fork-cache @@ -48,6 +54,14 @@ Each protocol is a single [`AmmAdapter`] implementation; the All protocol features are on by default. See [`docs/curve-adapter.md`](docs/curve-adapter.md) for the Curve adapter in depth. +> **Solidly offline caveat.** Solidly's `getAmountOut` reads more than the +> reserves its cold-start warms — the pool's `stable` flag and token `decimals`, +> plus an external `IPoolFactory(factory).getFee()` STATICCALL (which needs the +> factory's code and fee slots). With a live-backed cache these fetch lazily on +> the first quote; for fully-offline Solidly quotes, keep a backend attached or +> pre-warm that read-set. Uniswap V2/V3 and Curve cold-starts already cover their +> quote read-set (V3 within its warmed tick window). + ### Verified Pool Bytecode Seeding Known pool runtime bytecodes live in [`src/adapters/bytecodes`](src/adapters/bytecodes) @@ -70,9 +84,11 @@ entirely with `AdapterRegistry::with_code_seeding(false)`. Uniswap V3 has an embedded pool template and an explicit `uniswap_v3_code_seed` helper for callers that already know the pool immutables. Factory-discovered Uniswap V3 registrations carry the factory immutable in metadata, allowing -automatic V3 seeding without assuming a chain-global factory address. Balancer -and Curve pool bytecode seeding are also in scope for this bytecode workstream -before it is considered complete. +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. ### Factory-backed Discovery diff --git a/src/adapters/factory.rs b/src/adapters/factory.rs index fd1f747..c7fd722 100644 --- a/src/adapters/factory.rs +++ b/src/adapters/factory.rs @@ -876,9 +876,10 @@ impl ClFactorySpec { /// gated parity test covers the base slot); /// - the Slipstream quoter takes a `tickSpacing`-keyed struct, NOT the /// Uniswap `(…, fee, …)` struct this crate encodes, so wiring it as the V3 - /// quote target would send malformed calldata. Discovery-only for now; - /// its sim rides the caller's Uniswap-compatible quoter. See the module - /// `TODO(slice-A): Slipstream quoter ABI`. + /// quote target would send malformed calldata. Slipstream is therefore + /// discovery-only for quoting: its discovered `fee` is left unset, so + /// `simulate_swap` returns `MissingMetadata("V3 fee")` unless the caller + /// supplies a Slipstream-compatible quoter and fee. pub fn slipstream(factory: Address) -> Self { Self::tick_spacing_keyed( ProtocolId::Slipstream, @@ -1015,8 +1016,11 @@ impl SolidlyFactoryConfig { } /// Velodrome (Optimism) preset. Byte-identical Solidly-V2 shape to Aerodrome; - /// same placeholder base slot + layout and the same `PoolCreated` push topic. - /// See [`aerodrome`](Self::aerodrome) for the UNVERIFIED-constants caveat. + /// it reuses Aerodrome's base slot + layout and `PoolCreated` push topic. + /// Those constants are confirmed on Base for Aerodrome but are NOT yet + /// verified for Velodrome on Optimism — treat them as provisional and run the + /// gated parity check against an Optimism endpoint before relying on this + /// preset in production. pub fn velodrome(factory: Address) -> Self { Self::aerodrome(factory) } diff --git a/src/adapters/repair.rs b/src/adapters/repair.rs index 3baa2f3..b6b6627 100644 --- a/src/adapters/repair.rs +++ b/src/adapters/repair.rs @@ -58,9 +58,14 @@ pub(crate) fn v3_tick_range_effects( } /// Compute the sorted, deduped slot set a V3 liquidity event over -/// `[tick_lower, tick_upper]` must resync: the boundary `Tick.Info` slots -/// `{0, 3}` for each tick, the containing `tickBitmap` word(s) (deduped when the -/// boundary ticks share a word), and the global `liquidity` slot. +/// `[tick_lower, tick_upper]` must resync: all four `Tick.Info` slots for each +/// boundary tick, the containing `tickBitmap` word(s) (deduped when the boundary +/// ticks share a word), and the global `liquidity` slot. +/// +/// All four info words are refreshed (not just `{0, 3}`): a `Mint`/`Burn` that +/// flips a tick's initialized state sets/clears its `feeGrowthOutside{0,1}X128` +/// (words 1/2), which a later tick-crossing quote reads — so the resync must +/// cover them, matching what the cold-start planner warms. pub(crate) fn v3_tick_range_slots( layout: &V3StorageLayout, tick_lower: i32, @@ -70,8 +75,7 @@ pub(crate) fn v3_tick_range_slots( for tick in [tick_lower, tick_upper] { let keys = v3_tick_info_storage_keys_with_base(tick, layout.ticks_base_slot); - slots.push(keys[0]); - slots.push(keys[3]); + slots.extend_from_slice(&keys); } let mut words = [ @@ -102,9 +106,9 @@ mod tests { use super::*; use crate::adapters::storage::V3StorageLayout; - /// Golden slot-set check for distinct bitmap words. 2 ticks x {slot0, - /// slot3} + 2 bitmap words + liquidity = 7 deduped slots, matching the - /// independent reconstruction below. + /// Golden slot-set check for distinct bitmap words. 2 ticks x 4 info words + + /// 2 bitmap words + liquidity = 11 deduped slots, matching the independent + /// reconstruction below. #[test] fn slot_set_distinct_words() { let layout = V3StorageLayout::uniswap(60); @@ -114,8 +118,7 @@ mod tests { let mut expected = Vec::new(); for tick in [tick_lower, tick_upper] { let keys = v3_tick_info_storage_keys_with_base(tick, layout.ticks_base_slot); - expected.push(keys[0]); - expected.push(keys[3]); + expected.extend_from_slice(&keys); } for word in [ v3_word_position(tick_lower, layout.tick_spacing), @@ -131,11 +134,11 @@ mod tests { expected.dedup(); assert_eq!(got, expected); - assert_eq!(got.len(), 7); + assert_eq!(got.len(), 11); } - /// Boundary ticks in the same bitmap word collapse to one bitmap slot, - /// leaving 6 deduped slots. + /// Boundary ticks in the same bitmap word collapse to one bitmap slot: 2 + /// ticks x 4 info words + 1 shared bitmap word + liquidity = 10 deduped slots. #[test] fn slot_set_shared_word_dedupes() { let layout = V3StorageLayout::uniswap(60); @@ -146,6 +149,6 @@ mod tests { ); let got = v3_tick_range_slots(&layout, tick_lower, tick_upper); - assert_eq!(got.len(), 6); + assert_eq!(got.len(), 10); } } diff --git a/src/adapters/sim.rs b/src/adapters/sim.rs index 09f5c79..4b6d6fc 100644 --- a/src/adapters/sim.rs +++ b/src/adapters/sim.rs @@ -43,8 +43,14 @@ impl SwapQuote { /// Why a [`simulate_swap`](super::AmmAdapter::simulate_swap) could not produce a /// quote. +/// +/// Not `Clone`/`PartialEq` (the [`Execution`](Self::Execution) variant carries a +/// boxed source error that is neither), matching the crate's other +/// boxed-source facades ([`CacheError`](super::CacheError), +/// [`DriverError`](super::DriverError)). Match on the variant, or walk +/// [`source`](std::error::Error::source) for the underlying cause. #[non_exhaustive] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Debug)] pub enum SimError { /// The adapter does not implement swap simulation for its protocol. Unsupported(super::ProtocolId), @@ -54,8 +60,11 @@ pub enum SimError { Reverted, /// The quote call executed but its return data could not be decoded. MalformedOutput(&'static str), - /// The underlying `call_raw` failed (host/transact error). - Execution(String), + /// The underlying `call_raw` failed (host/transact error), carrying the + /// un-flattened cause. Downcast the payload (or walk + /// [`source`](std::error::Error::source)) — e.g. to + /// [`CacheError`](super::CacheError) — for typed handling. + Execution(Box), /// A catch-all for protocol-specific failures. Custom(String), } @@ -75,7 +84,14 @@ impl core::fmt::Display for SimError { } } -impl std::error::Error for SimError {} +impl std::error::Error for SimError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Execution(err) => Some(&**err as &(dyn std::error::Error + 'static)), + _ => None, + } + } +} /// Resolved quote-target addresses for swap simulation. /// @@ -141,7 +157,7 @@ pub fn quote_via_call( ) -> Result { match cache .call_raw(Address::ZERO, target, calldata, false) - .map_err(|e| SimError::Execution(e.to_string()))? + .map_err(|e| SimError::Execution(Box::new(e)))? { CallOutcome::Success { output, .. } => Ok(output), CallOutcome::Revert { .. } | CallOutcome::Halt { .. } => Err(SimError::Reverted), diff --git a/src/adapters/uniswap_v3.rs b/src/adapters/uniswap_v3.rs index f15de02..767ab9d 100644 --- a/src/adapters/uniswap_v3.rs +++ b/src/adapters/uniswap_v3.rs @@ -441,8 +441,8 @@ impl ConcentratedLiquidityAdapter { /// resolved. /// - Round 2 (`Strict`/`Eager` only) verifies **all** window bitmap words in one /// round. -/// - Round 3 (`Strict`/`Eager` only) verifies the `{0, 3}` info slots of every -/// tick initialized across the whole window in one round. +/// - Round 3 (`Strict`/`Eager` only) verifies all four `Tick.Info` words of +/// every tick initialized across the whole window in one round. /// /// `HotSlotsOnly` stops after round 1 (slot0 + liquidity — no bitmap/tick /// warming). `Lazy` stops after round 1 and defers the **window** of bitmap @@ -607,8 +607,15 @@ impl AdapterColdStartPlanner for UniswapV3ColdStartPlanner { } } V3Phase::BitmapWord => { - // Round 3: warm the {0, 3} info slots of every tick initialized - // across the whole window. Each window word's bitmap is extracted + // Round 3: warm ALL FOUR `Tick.Info` words of every tick + // initialized across the whole window. A tick-crossing swap quote + // reads the full struct — `liquidityGross`/`liquidityNet` (word 0), + // both `feeGrowthOutside{0,1}X128` (words 1/2), and the packed + // `tickCumulative`/`secondsPerLiquidity`/`secondsOutside`/ + // `initialized` (word 3) — so warming only {0, 3} left a hard + // tick-crossing quote lazily fetching words 1/2 (correct online, + // but not fully offline). Warming all four matches the one-shot + // full-sync program. Each window word's bitmap is extracted // adapter-locally: bit `i` set => tick `(word * 256 + i) * // tick_spacing`, skipping any tick outside [MIN_TICK, MAX_TICK]. let mut tick_slots: Vec<(Address, U256)> = Vec::new(); @@ -629,8 +636,7 @@ impl AdapterColdStartPlanner for UniswapV3ColdStartPlanner { tick_i, self.layout.ticks_base_slot, ); - tick_slots.push((self.address, keys[0])); - tick_slots.push((self.address, keys[3])); + tick_slots.extend(keys.iter().map(|key| (self.address, *key))); } } } diff --git a/tests/adapter_reactive.rs b/tests/adapter_reactive.rs index 3726443..6c8f59e 100644 --- a/tests/adapter_reactive.rs +++ b/tests/adapter_reactive.rs @@ -222,7 +222,7 @@ fn word_pos(tick: i32, tick_spacing: i32) -> i16 { } /// Independent oracle for the slot set a V3 liquidity-event repair must resync: -/// the boundary `Tick.Info` slots {0, 3}, the (deduped) containing bitmap words, +/// all four boundary `Tick.Info` slots, the (deduped) containing bitmap words, /// and the global liquidity slot. Returned sorted and deduped. fn expected_tick_repair_slots( layout: V3StorageLayout, @@ -232,8 +232,7 @@ fn expected_tick_repair_slots( let mut slots = Vec::new(); for tick in [tick_lower, tick_upper] { let keys = v3_tick_info_storage_keys_with_base(tick, layout.ticks_base_slot); - slots.push(keys[0]); - slots.push(keys[3]); + slots.extend_from_slice(&keys); } let mut words = vec![ word_pos(tick_lower, layout.tick_spacing), @@ -897,12 +896,12 @@ async fn v3_burn_same_word_dedupes_bitmap_slot() -> Result<()> { let mut got = slots.clone(); got.sort_unstable(); got.dedup(); - // 2 ticks x {slot0, slot3} + 1 shared bitmap word + liquidity = 6 slots. + // 2 ticks x 4 info words + 1 shared bitmap word + liquidity = 10 slots. assert_eq!( got, expected_tick_repair_slots(layout, tick_lower, tick_upper) ); - assert_eq!(got.len(), 6); + assert_eq!(got.len(), 10); Ok(()) } diff --git a/tests/adapter_swap_sim.rs b/tests/adapter_swap_sim.rs index 6ac3dcd..382ff74 100644 --- a/tests/adapter_swap_sim.rs +++ b/tests/adapter_swap_sim.rs @@ -177,7 +177,7 @@ async fn v2_simulate_swap_reverting_target_is_reverted() -> Result<()> { &config, ) .expect_err("reverting router must error"); - assert_eq!(err, SimError::Reverted); + assert!(matches!(err, SimError::Reverted)); assert!(asserter.read_q().is_empty(), "must be fully offline"); Ok(()) } @@ -256,7 +256,7 @@ async fn v3_simulate_swap_reverting_target_is_reverted() -> Result<()> { &config, ) .expect_err("reverting quoter must error"); - assert_eq!(err, SimError::Reverted); + assert!(matches!(err, SimError::Reverted)); assert!(asserter.read_q().is_empty(), "must be fully offline"); Ok(()) } @@ -288,7 +288,7 @@ async fn v3_simulate_swap_missing_fee_is_missing_metadata() -> Result<()> { &config, ) .expect_err("missing fee must error"); - assert_eq!(err, SimError::MissingMetadata("V3 fee")); + assert!(matches!(err, SimError::MissingMetadata("V3 fee"))); Ok(()) } @@ -391,7 +391,7 @@ async fn balancer_simulate_swap_reverting_vault_is_reverted() -> Result<()> { &config, ) .expect_err("reverting vault must error"); - assert_eq!(err, SimError::Reverted); + assert!(matches!(err, SimError::Reverted)); assert!(asserter.read_q().is_empty(), "must be fully offline"); Ok(()) } @@ -673,7 +673,7 @@ async fn solidly_simulate_swap_reverting_pool_is_reverted() -> Result<()> { &SimConfig::default(), ) .expect_err("reverting pool must error"); - assert_eq!(err, SimError::Reverted); + assert!(matches!(err, SimError::Reverted)); assert!(asserter.read_q().is_empty(), "must be fully offline"); Ok(()) } @@ -770,7 +770,10 @@ async fn curve_simulate_swap_token_not_in_pool_is_error() -> Result<()> { ) .expect_err("token outside the pool must error"); // Specific variant: the call must never be built/run (never Reverted). - assert_eq!(err, SimError::MissingMetadata("Curve token not in pool")); + assert!(matches!( + err, + SimError::MissingMetadata("Curve token not in pool") + )); Ok(()) } @@ -803,7 +806,7 @@ async fn curve_simulate_swap_without_coins_is_error() -> Result<()> { &SimConfig::default(), ) .expect_err("missing coins must error"); - assert_eq!(err, SimError::MissingMetadata("Curve coins")); + assert!(matches!(err, SimError::MissingMetadata("Curve coins"))); Ok(()) } @@ -843,7 +846,7 @@ async fn curve_simulate_swap_self_swap_is_error() -> Result<()> { &SimConfig::default(), ) .expect_err("self-swap must error"); - assert_eq!(err, SimError::Custom("Curve token_in == token_out".into())); + assert!(matches!(err, SimError::Custom(ref s) if s == "Curve token_in == token_out")); assert!(asserter.read_q().is_empty(), "must not touch the backend"); Ok(()) } @@ -934,7 +937,7 @@ async fn curve_simulate_swap_reverting_pool_is_reverted() -> Result<()> { &SimConfig::default(), ) .expect_err("reverting pool must error"); - assert_eq!(err, SimError::Reverted); + assert!(matches!(err, SimError::Reverted)); assert!(asserter.read_q().is_empty(), "must be fully offline"); Ok(()) } diff --git a/tests/cold_start_adoption.rs b/tests/cold_start_adoption.rs index ddf76a8..f919fa5 100644 --- a/tests/cold_start_adoption.rs +++ b/tests/cold_start_adoption.rs @@ -519,6 +519,8 @@ async fn v3_cold_start_warms_neighbouring_tick_words() -> Result<()> { ((pool, key_wp1), v3_bit(tick_wp1, spacing)), ((pool, key_wm1), v3_bit(tick_wm1, spacing)), ((pool, info_w0[0]), U256::from(1_u64)), + ((pool, info_w0[1]), U256::from(1_u64)), + ((pool, info_w0[2]), U256::from(1_u64)), ((pool, info_w0[3]), U256::from(1_u64)), ((pool, info_wp1[0]), U256::from(1_u64)), ((pool, info_wp1[3]), U256::from(1_u64)), @@ -546,6 +548,12 @@ async fn v3_cold_start_warms_neighbouring_tick_words() -> Result<()> { // Current word still warmed (regression). assert!(cache.cached_storage_value(pool, key_w0).is_some()); assert!(cache.cached_storage_value(pool, info_w0[0]).is_some()); + // All FOUR Tick.Info words of an initialized tick are warmed: a tick-crossing + // quote also reads feeGrowthOutside{0,1}X128 (words 1/2), so warming only + // {0, 3} would force a lazy fetch mid-quote (not fully offline). + assert!(cache.cached_storage_value(pool, info_w0[1]).is_some()); + assert!(cache.cached_storage_value(pool, info_w0[2]).is_some()); + assert!(cache.cached_storage_value(pool, info_w0[3]).is_some()); // Neighbouring words + their initialized ticks warmed (the new behaviour). assert!( cache.cached_storage_value(pool, key_wp1).is_some(), diff --git a/tests/discovery_cl_rpc.rs b/tests/discovery_cl_rpc.rs index f4dd3eb..7898dd2 100644 --- a/tests/discovery_cl_rpc.rs +++ b/tests/discovery_cl_rpc.rs @@ -19,18 +19,20 @@ //! # (its `eth-mainnet` host is swapped to `base-mainnet`). //! ``` //! -//! ## TODO(slice-A): what these tests pin +//! ## What these tests pin //! -//! - **Pancake V3**: the preset defaults `get_pool` / `feeAmountTickSpacing` base -//! slots to the Uniswap values (5 / 4) with `verify_derivations` OFF, because -//! Pancake's factory storage layout was not confirmed offline. If -//! `pancake_get_pool_base_slot_matches_getter` passes, base slot 5 is correct -//! and the preset can flip `verify_derivations` on (the CREATE2 deployer + -//! init-code hash are already pinned). If it FAILS, the base slot is wrong and -//! must be corrected in `ClFactorySpec::pancake_v3`. -//! - **Slipstream**: the preset is discovery-only (no `create2`, no `quoter`). -//! `slipstream_get_pool_base_slot_matches_getter` pins the `getPool` base slot -//! + the tickSpacing salt encoding against Aerodrome's live CLFactory on Base. +//! - **Pancake V3**: the preset uses Pancake's OWN factory layout — `get_pool` +//! base slot 2 and `feeAmountTickSpacing` base slot 1 (not the Uniswap 5 / 4) — +//! with `verify_derivations` ON and the CREATE2 deployer + init-code hash +//! pinned. `pancake_get_pool_base_slot_matches_getter` confirms that base slot +//! against the live factory getter and `pancake_create2_matches_getter` +//! confirms the CREATE2 derivation; a regression there means the constants in +//! `ClFactorySpec::pancake_v3` are wrong. +//! - **Slipstream**: the preset is discovery-only (no `create2`, no `quoter` — its +//! quoter takes a different, tickSpacing-keyed ABI, so quoting rides a +//! caller-supplied compatible quoter). `slipstream_get_pool_base_slot_matches_getter` +//! confirms the `getPool` base slot + the tickSpacing salt encoding against +//! Aerodrome's live CLFactory on Base. #![cfg(feature = "uniswap-v3")] @@ -140,10 +142,11 @@ async fn storage_addr( Ok(Address::from_slice(&word.to_be_bytes::<32>()[12..])) } -/// Pancake V3: the fee-keyed `getPool` base slot (preset default 5) must hold the -/// same pool the factory's `getPool(t0,t1,fee)` getter returns. Proves the base -/// slot on-chain; if it passes, `ClFactorySpec::pancake_v3` can enable -/// `verify_derivations`. +/// Pancake V3: the fee-keyed `getPool` base slot (preset slot 2 — Pancake's own +/// layout, not the Uniswap 5) must hold the same pool the factory's +/// `getPool(t0,t1,fee)` getter returns — confirming the shipped +/// `ClFactorySpec::pancake_v3` constant, which already runs with +/// `verify_derivations` on. #[tokio::test(flavor = "multi_thread")] #[ignore = "requires E2E_RPC_URL archive node; run with --ignored"] async fn pancake_get_pool_base_slot_matches_getter() -> Result<()> { @@ -182,7 +185,7 @@ async fn pancake_get_pool_base_slot_matches_getter() -> Result<()> { assert_eq!( from_storage, getter, "Pancake getPool base slot {} is WRONG for fee {fee}: storage={from_storage:?} getter={getter:?}. \ - Fix ClFactorySpec::pancake_v3's get_pool_base_slot (TODO slice-A).", + The shipped ClFactorySpec::pancake_v3 get_pool_base_slot has regressed.", spec.get_pool_base_slot ); } @@ -232,7 +235,7 @@ async fn pancake_create2_matches_getter() -> Result<()> { assert_eq!( derived, getter, "Pancake CREATE2 (deployer {deployer:?}, init hash {:?}) does not reproduce the \ - getter pool for fee {fee}: derived={derived:?} getter={getter:?} (TODO slice-A).", + getter pool for fee {fee}: derived={derived:?} getter={getter:?}.", create2.init_code_hash ); } @@ -267,7 +270,7 @@ async fn pancake_discovery_resolves_live_pool() -> Result<()> { )?; assert!( !found.is_empty(), - "Pancake preset found no USDC/WETH pool — likely a wrong get_pool_base_slot (TODO slice-A)" + "Pancake preset found no USDC/WETH pool — likely a wrong get_pool_base_slot" ); for pool in &found { assert!( @@ -322,7 +325,7 @@ async fn slipstream_get_pool_base_slot_matches_getter() -> Result<()> { from_storage, getter, "Slipstream getPool base slot {} (spacing {SLIPSTREAM_WETH_USDC_SPACING}) is WRONG: \ storage={from_storage:?} getter={getter:?}. Fix ClFactorySpec::slipstream's \ - get_pool_base_slot (TODO slice-A).", + get_pool_base_slot.", spec.get_pool_base_slot ); Ok(()) diff --git a/tests/discovery_solidly_rpc.rs b/tests/discovery_solidly_rpc.rs index 03d41e8..9ae50b5 100644 --- a/tests/discovery_solidly_rpc.rs +++ b/tests/discovery_solidly_rpc.rs @@ -22,20 +22,19 @@ //! E2E_BASE_RPC_URL= cargo test --test discovery_solidly_rpc -- --ignored //! ``` //! -//! ## TODO(slice-B): what these tests pin +//! ## What these tests pin //! -//! The `SolidlyFactoryConfig::aerodrome` preset ships with a PLACEHOLDER -//! `get_pool_base_slot` and PLACEHOLDER storage layout, `verify_derivations` OFF, -//! because Aerodrome's factory storage layout (the `_getPool` mapping is a -//! private variable) and pool storage layout were not confirmed offline. -//! - `aerodrome_get_pool_base_slot_matches_getter` pins the `_getPool` base slot -//! (and the `bool` salt encoding). If it FAILS, correct -//! `SOLIDLY_GET_POOL_BASE_SLOT` in `factory.rs`. -//! - `aerodrome_storage_layout_matches_getters` pins the reserve/token slots. If -//! it FAILS, correct `SOLIDLY_PLACEHOLDER_LAYOUT` in `factory.rs`. -//! -//! Once both pass, the preset can be updated with the confirmed constants (and a -//! CREATE2 init hash added) and `verify_derivations` flipped on. +//! The `SolidlyFactoryConfig::aerodrome` preset ships the on-chain-confirmed +//! `get_pool_base_slot` (5) and reserve/token storage layout; these gated tests +//! confirm them against Aerodrome's live factory + a real pool on Base. +//! `verify_derivations` stays OFF for this preset because no CREATE2 init-code +//! hash is pinned for Aerodrome pools (unlike the CL presets), so there is no +//! derivation to cross-check — discovery relies on the factory storage read. +//! - `aerodrome_get_pool_base_slot_matches_getter` confirms the `_getPool` base +//! slot (and the `bool` salt encoding); a regression means +//! `SOLIDLY_GET_POOL_BASE_SLOT` in `factory.rs` is wrong. +//! - `aerodrome_storage_layout_matches_getters` confirms the reserve/token slots; +//! a regression means `SOLIDLY_AERODROME_LAYOUT` in `factory.rs` is wrong. #![cfg(feature = "solidly-v2")] @@ -161,7 +160,7 @@ async fn storage_word( .context("eth_getStorageAt") } -/// Aerodrome: the `getPool[t0][t1][stable]` base slot (preset placeholder) must +/// Aerodrome: the `getPool[t0][t1][stable]` base slot (preset slot 5) must /// hold the same pool the factory's `getPool(t0,t1,stable)` getter returns, for /// BOTH variants that exist. Proves the base slot + `bool` salt encoding on-chain. #[tokio::test(flavor = "multi_thread")] @@ -197,7 +196,7 @@ async fn aerodrome_get_pool_base_slot_matches_getter() -> Result<()> { from_storage, getter, "Aerodrome getPool base slot {} is WRONG for stable={stable}: \ storage={from_storage:?} getter={getter:?}. Fix SOLIDLY_GET_POOL_BASE_SLOT / the \ - aerodrome preset (TODO slice-B).", + shipped aerodrome preset has regressed.", config.get_pool_base_slot ); } @@ -208,7 +207,7 @@ async fn aerodrome_get_pool_base_slot_matches_getter() -> Result<()> { Ok(()) } -/// Aerodrome: the preset's placeholder [`SolidlyStorageLayout`] must match the +/// Aerodrome: the preset's [`SolidlyStorageLayout`] must match the /// pool's public getters — `reserve0`/`reserve1` == `getReserves()` and /// `token0`/`token1` == `tokens()` — read straight from storage. Pins the layout. #[tokio::test(flavor = "multi_thread")] @@ -259,25 +258,25 @@ async fn aerodrome_storage_layout_matches_getters() -> Result<()> { assert_eq!( slot_reserve0, reserves.reserve0, "Aerodrome reserve0 slot {} WRONG: storage={slot_reserve0} getter={}. Fix \ - SOLIDLY_PLACEHOLDER_LAYOUT (TODO slice-B).", + SOLIDLY_AERODROME_LAYOUT has regressed.", layout.reserve0_slot, reserves.reserve0 ); assert_eq!( slot_reserve1, reserves.reserve1, "Aerodrome reserve1 slot {} WRONG: storage={slot_reserve1} getter={}. Fix \ - SOLIDLY_PLACEHOLDER_LAYOUT (TODO slice-B).", + SOLIDLY_AERODROME_LAYOUT has regressed.", layout.reserve1_slot, reserves.reserve1 ); assert_eq!( slot_token0, toks.token0, "Aerodrome token0 slot {} WRONG: storage={slot_token0:?} getter={:?}. Fix \ - SOLIDLY_PLACEHOLDER_LAYOUT (TODO slice-B).", + SOLIDLY_AERODROME_LAYOUT has regressed.", layout.token0_slot, toks.token0 ); assert_eq!( slot_token1, toks.token1, "Aerodrome token1 slot {} WRONG: storage={slot_token1:?} getter={:?}. Fix \ - SOLIDLY_PLACEHOLDER_LAYOUT (TODO slice-B).", + SOLIDLY_AERODROME_LAYOUT has regressed.", layout.token1_slot, toks.token1 ); Ok(()) @@ -310,7 +309,7 @@ async fn aerodrome_discovery_resolves_live_pool() -> Result<()> { )?; assert!( !found.is_empty(), - "Aerodrome preset found no WETH/USDC pool — likely a wrong get_pool_base_slot (TODO slice-B)" + "Aerodrome preset found no WETH/USDC pool — likely a wrong get_pool_base_slot" ); for pool in &found { assert!( diff --git a/tests/facade_typing.rs b/tests/facade_typing.rs index 58e4d7b..2c24076 100644 --- a/tests/facade_typing.rs +++ b/tests/facade_typing.rs @@ -108,7 +108,7 @@ fn quote_via_call_is_public_and_maps_revert_to_sim_error() { }, }; let err = quote_via_call(&mut cache, Address::ZERO, Bytes::new()).unwrap_err(); - assert_eq!(err, SimError::Reverted); + assert!(matches!(err, SimError::Reverted)); } #[test] @@ -171,4 +171,15 @@ fn errors_preserve_their_source_chain() { }; let source = std::error::Error::source(&driver_err).expect("DriverError keeps its cause"); assert_eq!(source.to_string(), "malformed log: boom"); + + // SimError::Execution carries the boxed cache error un-flattened (not a + // stringified copy): source() exposes it and it downcasts to the typed + // CacheError, so a consumer can distinguish an execution failure's cause. + let sim_err = SimError::Execution(Box::new(CacheError::Backend("call_raw failed".into()))); + let source = std::error::Error::source(&sim_err).expect("SimError::Execution keeps its cause"); + assert!( + source.downcast_ref::().is_some(), + "the execution cause downcasts to the typed CacheError" + ); + assert_eq!(source.to_string(), "cache backend error: call_raw failed"); } From d0a7225c02723d66a6bdee043efedc5cea2e21a0 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 12:47:33 +0100 Subject: [PATCH 3/9] hardening(tier-2): ergonomics + user-facing docs Part 1 of the Tier 2 polish PR (missing_docs sweep follows as its own commit). - Configurable quote sender: `SimConfig.from` (default `Address::ZERO`) + a new public `quote_via_call_from(cache, from, target, calldata)`; `quote_via_call` now delegates to it with ZERO (unchanged behavior). Every adapter's `simulate_swap` threads `config.from` so a caller can quote against a target that gates on `msg.sender`. facade_typing gains a sender-threading test. - New `docs/protocol-support-matrix.md`: a per-protocol v0.1 capability matrix (cold-start, offline-after-cold-start, exact-write vs resync, factory discovery, known limitations), linked from the README's protocol table. - README: note that the published crate excludes `tests/`, so `cargo test` on a crates.io download runs only inline unit tests (clone for the full suite). - docs/benchmarks.md: a point-in-time / reproduce note on the results (medians are one host/run; `cargo bench` emits full Criterion stats + HTML reports). Full gate green: tests (all/default/no-default), clippy -D warnings, fmt, doc. Co-Authored-By: Claude Opus 4.8 --- README.md | 10 ++++- docs/benchmarks.md | 7 ++++ docs/protocol-support-matrix.md | 45 ++++++++++++++++++++++ src/adapters/balancer_v2.rs | 6 +-- src/adapters/curve.rs | 8 ++-- src/adapters/mod.rs | 2 +- src/adapters/sim.rs | 31 +++++++++++++++- src/adapters/solidly_v2.rs | 6 +-- src/adapters/uniswap_v2.rs | 4 +- src/adapters/uniswap_v3.rs | 4 +- tests/facade_typing.rs | 66 +++++++++++++++++++++++++++++++++ 11 files changed, 172 insertions(+), 17 deletions(-) create mode 100644 docs/protocol-support-matrix.md diff --git a/README.md b/README.md index 661ab7c..15fc29b 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,10 @@ Each protocol is a single [`AmmAdapter`] implementation; the | Solidly V2 (Aerodrome / Velodrome) | `solidly-v2` | pool `getAmountOut` | named slots (config layout) | `Sync` → two exact slot writes | | **Curve** (StableSwap, StableSwap-NG, CryptoSwap v2, Tricrypto-NG) | `curve` | pool `get_dy` | discover → verify (`get_dy` read-set) | `TokenExchange` + liquidity events → slot resync | -All protocol features are on by default. See [`docs/curve-adapter.md`](docs/curve-adapter.md) +All protocol features are on by default. See +[`docs/protocol-support-matrix.md`](docs/protocol-support-matrix.md) for the +per-protocol capability matrix (offline-after-cold-start, exact-write vs resync, +discovery, and known limitations), and [`docs/curve-adapter.md`](docs/curve-adapter.md) for the Curve adapter in depth. > **Solidly offline caveat.** Solidly's `getAmountOut` reads more than the @@ -385,6 +388,11 @@ cargo test # unit + offline integration tests cargo test --no-default-features # protocol-neutral core ``` +These run from a clone of the repository. The published crate **excludes the +integration test suite** (`tests/`) to stay lean, so `cargo test` on a crates.io +download exercises only the inline unit tests — clone the repo for the full +suite. + Network-dependent tests are env-gated and `#[ignore]`d. With an archive RPC they pin a block, cold-start a real pool, and assert `simulate_swap` **equals the on-chain quote** at the same block (`eth_call`), plus a live WebSocket soak that diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 1a5f9eb..5ae1ebe 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -29,6 +29,13 @@ qualitatively, to the other ways people price AMM swaps. ## Results +> Point-in-time medians from a single host and run (mid-2026, the Methodology +> host above) — treat them as order-of-magnitude, and re-run the reproduce +> command for numbers on your own machine. `cargo bench` prints full Criterion +> statistics (mean / median / std-dev / outliers) to stdout and writes HTML +> reports under `target/criterion/`; the medians below are the headline figures +> from that output. + ### `simulate_swap` — one offline quote (the repeated hot path) | Protocol | Quote entrypoint | Median / quote | ≈ Quotes/sec | diff --git a/docs/protocol-support-matrix.md b/docs/protocol-support-matrix.md new file mode 100644 index 0000000..cb33071 --- /dev/null +++ b/docs/protocol-support-matrix.md @@ -0,0 +1,45 @@ +# Protocol support matrix (v0.1) + +What each protocol adapter actually guarantees, so you can tell at a glance where +a pool is fully offline, where the reactive path avoids RPC, and where a live +backend or extra setup is still needed. This complements the summary table in the +[README](../README.md#supported-protocols). + +## Capabilities + +| Protocol (feature) | Cold-start | Offline quote after cold-start | Reactive event → state | Factory discovery | Known limitations | +| --- | --- | --- | --- | --- | --- | +| **Uniswap V2** (`uniswap-v2`) | named slots (token0/1, reserves) | ✅ fully offline | `Sync` → **exact** masked write, no RPC | ✅ `getPair[t0][t1]` | — | +| **Uniswap V3** (`uniswap-v3`) | slot0 + liquidity + a bounded multi-word tick window (all four `Tick.Info` words), or the one-shot full-range program | ✅ offline within the warmed tick window; a swap crossing beyond it lazily fetches (or use the one-shot full sync for zero-lazy) | `Swap` → **exact** slot0/liquidity; `Mint`/`Burn` → bounded tick-range **resync** | ✅ fee-keyed `getPool[t0][t1][fee]` | — | +| **PancakeSwap V3** (`pancake-v3`) | as Uniswap V3 (Pancake slot layout) | ✅ as Uniswap V3 | as Uniswap V3 (Pancake `Swap` topic) | ✅ fee-keyed | one-shot full sync uses the layout-only `core` spec (Pancake's fee-growth/observation slots are unverified) | +| **Slipstream / Aerodrome CL** (`slipstream`) | as Uniswap V3 (Slipstream slot layout) | ⚠️ **discovery + cold-start only** | as Uniswap V3 | ✅ tickSpacing-keyed `getPool[t0][t1][spacing]` | discovered `fee` is left unset (its quoter takes a different ABI); `simulate_swap` returns `MissingMetadata` unless the caller supplies a compatible quoter + fee | +| **Balancer V2** (`balancer-v2`) | discover→verify (`getPoolTokens` read-set) | ✅ (the vault's code is lazily fetched on the first quote) | `Swap` → balance-slot **resync** | ❌ not shipped (no on-chain token→pool index; needs an async log scan) | register pools explicitly | +| **Solidly V2** (`solidly-v2`) | named slots (config layout: reserves + tokens) | ⚠️ `getAmountOut` also reads the pool's `stable` flag + token `decimals` and STATICCALLs `factory.getFee()`, so the first offline quote lazily fetches those (and the factory code) unless a backend is attached or they are pre-warmed | `Sync` → **exact** two-slot write, no RPC | ✅ `getPool[t0][t1][bool stable]` (Aerodrome preset verified on Base) | Velodrome/Optimism reuses Aerodrome's constants — unverified on Optimism | +| **Curve** — StableSwap, StableSwap-NG, CryptoSwap v2, Tricrypto-NG (`curve`) | discover→verify (`get_dy` read-set) | ✅ fully offline | `TokenExchange` + liquidity events → discovered-slot **resync** | ❌ not shipped (needs the Vyper MetaRegistry view call) | metapools / lending pools out of scope (their `get_dy` makes external calls a pool-only capture misses) | + +Legend: **exact** = the event carries absolute state, applied with no RPC; +**resync** = the event carries only deltas, so the affected slots are re-verified +via a bounded, hash-pinned request (block trace → bulk-storage / point-read +fallback), executed by [`AmmSyncEngine`](../src/adapters/sync_manager.rs). A +"resync" is not RPC in the quote hot path — it is a targeted refresh triggered by +a liquidity/swap event, resolved off the block's own trace where possible. + +## Notes + +- **First-quote lazy fetch.** A warmed pool quotes offline, but a contract's own + runtime *code* is fetched lazily on first use unless it was bytecode-seeded + (Uniswap V2/V3 are; Balancer/Curve/Solidly fetch code lazily). With a + live-backed [`EvmCache`](https://github.com/KaiCode2/evm-fork-cache) this is a + one-time cost; against a pinned/offline backend, seed or pre-warm what a quote + reads. See the README's *Solidly offline caveat* for the one protocol whose + quote read-set exceeds what its cold-start warms. +- **Extending coverage.** A protocol without a shipped adapter or discovery + mechanism is not a fork: `register_adapter(Arc)` adds a novel + simulation engine and `PoolDiscovery::with_factory(Box)` adds a + novel discovery mechanism. See [`docs/writing-an-adapter.md`](writing-an-adapter.md) + and [`docs/pool-discovery.md`](pool-discovery.md). +- **Verification.** The factory-preset storage constants are confirmed on-chain by + the gated `discovery_cl_rpc` / `discovery_solidly_rpc` parity tests, and each + protocol's `simulate_swap` is checked against the on-chain `eth_call` quote at a + pinned block by `adapter_swap_sim_rpc` (see [`docs/benchmarks.md`](benchmarks.md) + for the reproduce commands). diff --git a/src/adapters/balancer_v2.rs b/src/adapters/balancer_v2.rs index 732ed1e..e9c8b0b 100644 --- a/src/adapters/balancer_v2.rs +++ b/src/adapters/balancer_v2.rs @@ -4,7 +4,7 @@ use super::cold_start::{ }; use super::sim::{ BatchSwapStep, FundManagement, SimConfig, SimError, SwapQuote, queryBatchSwapCall, - quote_via_call, + quote_via_call_from, }; use super::{ AdapterCache, AdapterEvent, AdapterEventError, AdapterEventKind, AdapterEventResult, @@ -143,7 +143,7 @@ impl AmmAdapter for BalancerV2Adapter { token_in: Address, token_out: Address, amount_in: U256, - _config: &SimConfig, + config: &SimConfig, ) -> Result { let (vault, pool_id) = match (&pool.metadata, pool.key.bytes32()) { (ProtocolMetadata::BalancerV2(metadata), Some(pool_id)) => { @@ -182,7 +182,7 @@ impl AmmAdapter for BalancerV2Adapter { .abi_encode(), ); - let output = quote_via_call(cache, vault, calldata)?; + let output = quote_via_call_from(cache, config.from, vault, calldata)?; let asset_deltas = queryBatchSwapCall::abi_decode_returns_validate(&output) .map_err(|_| SimError::MalformedOutput("queryBatchSwap return"))?; diff --git a/src/adapters/curve.rs b/src/adapters/curve.rs index e5abaec..976a444 100644 --- a/src/adapters/curve.rs +++ b/src/adapters/curve.rs @@ -36,7 +36,7 @@ use super::cold_start::{ AdapterColdStartPlanner, ColdStartCall, ColdStartPlan, ColdStartResults, ColdStartRunReport, ColdStartStep, SlotFetch, }; -use super::sim::{SimConfig, SimError, SwapQuote, get_dyCall, quote_via_call}; +use super::sim::{SimConfig, SimError, SwapQuote, get_dyCall, quote_via_call_from}; use super::{ AdapterCache, AdapterEvent, AdapterEventError, AdapterEventKind, AdapterEventResult, AmmAdapter, ColdStartOutcome, ColdStartPolicy, ColdStartReport, CurveMetadata, CurveVariant, @@ -408,7 +408,7 @@ impl AmmAdapter for CurveAdapter { token_in: Address, token_out: Address, amount_in: U256, - _config: &SimConfig, + config: &SimConfig, ) -> Result { let pool_address = pool .key @@ -457,7 +457,7 @@ impl AmmAdapter for CurveAdapter { } .abi_encode(), ); - let output = quote_via_call(cache, pool_address, calldata)?; + let output = quote_via_call_from(cache, config.from, pool_address, calldata)?; get_dyCall::abi_decode_returns_validate(&output) .map_err(|_| SimError::MalformedOutput("get_dy return"))? } @@ -470,7 +470,7 @@ impl AmmAdapter for CurveAdapter { } .abi_encode(), ); - let output = quote_via_call(cache, pool_address, calldata)?; + let output = quote_via_call_from(cache, config.from, pool_address, calldata)?; super::sim::CurveCryptoSwap::get_dyCall::abi_decode_returns_validate(&output) .map_err(|_| SimError::MalformedOutput("CryptoSwap get_dy return"))? } diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 20f8140..706d6e5 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -65,7 +65,7 @@ pub use factory::{ pub use factory::{SolidlyFactory, SolidlyFactoryConfig}; pub use reactive::AmmReactiveHandler; pub use registry::{AdapterRegistry, RegistryError, SubscriptionSpec}; -pub use sim::{SimConfig, SimError, SwapQuote, quote_via_call}; +pub use sim::{SimConfig, SimError, SwapQuote, quote_via_call, quote_via_call_from}; #[cfg(feature = "solidly-v2")] pub use storage::SolidlyStorageLayout; pub use storage_sync::{ diff --git a/src/adapters/sim.rs b/src/adapters/sim.rs index 4b6d6fc..0041c5a 100644 --- a/src/adapters/sim.rs +++ b/src/adapters/sim.rs @@ -106,6 +106,11 @@ pub struct SimConfig { pub v3_quoter: Address, /// Uniswap V2 `UniswapV2Router02` quote target. pub v2_router: Address, + /// The `msg.sender` (`from`) each quote call runs as. Defaults to + /// [`Address::ZERO`]; override for the rare quoter/router that gates its + /// output on the caller. Threaded into [`quote_via_call_from`] by every + /// adapter's `simulate_swap`. + pub from: Address, } /// Ethereum-mainnet Uniswap V3 `QuoterV2`. @@ -119,6 +124,7 @@ impl Default for SimConfig { Self { v3_quoter: MAINNET_V3_QUOTER_V2, v2_router: MAINNET_V2_ROUTER_02, + from: Address::ZERO, } } } @@ -136,6 +142,13 @@ impl SimConfig { self.v2_router = router; self } + + /// Override the `msg.sender` (`from`) quote calls run as (default + /// [`Address::ZERO`]). Use for a quote target that gates on the caller. + pub fn with_from(mut self, from: Address) -> Self { + self.from = from; + self + } } /// Run a quote `calldata` against `target` on the cache and return the raw @@ -150,13 +163,29 @@ impl SimConfig { /// This is the public helper that custom-adapter authors use to run a quote /// entrypoint: build the target's quote calldata, call this, then decode the /// returned [`Bytes`] into the protocol's output. +/// +/// Runs the call as `from = ZERO`. Use [`quote_via_call_from`] when the quote +/// target gates its output on `msg.sender`. pub fn quote_via_call( cache: &mut dyn AdapterCache, target: Address, calldata: Bytes, +) -> Result { + quote_via_call_from(cache, Address::ZERO, target, calldata) +} + +/// Like [`quote_via_call`], but runs the quote as `from` (`msg.sender`) rather +/// than [`Address::ZERO`]. Adapters thread [`SimConfig::from`] here so a caller +/// can quote against a target that gates behavior on the sender; the default +/// [`SimConfig::from`] keeps the `ZERO`-sender behavior. +pub fn quote_via_call_from( + cache: &mut dyn AdapterCache, + from: Address, + target: Address, + calldata: Bytes, ) -> Result { match cache - .call_raw(Address::ZERO, target, calldata, false) + .call_raw(from, target, calldata, false) .map_err(|e| SimError::Execution(Box::new(e)))? { CallOutcome::Success { output, .. } => Ok(output), diff --git a/src/adapters/solidly_v2.rs b/src/adapters/solidly_v2.rs index 83afac5..26291b2 100644 --- a/src/adapters/solidly_v2.rs +++ b/src/adapters/solidly_v2.rs @@ -3,7 +3,7 @@ use super::cold_start::{ SlotFetch, }; use super::factory::{FactoryConfig, PoolFactory, SolidlyFactory}; -use super::sim::{SimConfig, SimError, SwapQuote, getAmountOutCall, quote_via_call}; +use super::sim::{SimConfig, SimError, SwapQuote, getAmountOutCall, quote_via_call_from}; use super::storage::{SolidlyStorageLayout, decode_address_slot}; use super::{ AdapterCache, AdapterEvent, AdapterEventError, AdapterEventKind, AdapterEventResult, @@ -187,7 +187,7 @@ impl AmmAdapter for SolidlyV2Adapter { token_in: Address, _token_out: Address, amount_in: U256, - _config: &SimConfig, + config: &SimConfig, ) -> Result { let pool_address = pool .key @@ -202,7 +202,7 @@ impl AmmAdapter for SolidlyV2Adapter { .abi_encode(), ); - let output = quote_via_call(cache, pool_address, calldata)?; + let output = quote_via_call_from(cache, config.from, pool_address, calldata)?; let amount_out = getAmountOutCall::abi_decode_returns_validate(&output) .map_err(|_| SimError::MalformedOutput("getAmountOut return"))?; Ok(SwapQuote::new(amount_out)) diff --git a/src/adapters/uniswap_v2.rs b/src/adapters/uniswap_v2.rs index ad1dfe0..f6aca9f 100644 --- a/src/adapters/uniswap_v2.rs +++ b/src/adapters/uniswap_v2.rs @@ -4,7 +4,7 @@ use super::cold_start::{ SlotFetch, }; use super::factory::{FactoryConfig, PoolFactory, UniswapV2Factory}; -use super::sim::{SimConfig, SimError, SwapQuote, getAmountsOutCall, quote_via_call}; +use super::sim::{SimConfig, SimError, SwapQuote, getAmountsOutCall, quote_via_call_from}; use super::storage::{V2_RESERVES_SLOT, V2_TOKEN0_SLOT, V2_TOKEN1_SLOT, decode_address_slot}; use super::{ AdapterCache, AdapterEvent, AdapterEventError, AdapterEventKind, AdapterEventResult, @@ -166,7 +166,7 @@ impl AmmAdapter for UniswapV2Adapter { .abi_encode(), ); - let output = quote_via_call(cache, config.v2_router, calldata)?; + let output = quote_via_call_from(cache, config.from, config.v2_router, calldata)?; let amounts = getAmountsOutCall::abi_decode_returns_validate(&output) .map_err(|_| SimError::MalformedOutput("getAmountsOut return"))?; diff --git a/src/adapters/uniswap_v3.rs b/src/adapters/uniswap_v3.rs index 767ab9d..b05d755 100644 --- a/src/adapters/uniswap_v3.rs +++ b/src/adapters/uniswap_v3.rs @@ -5,7 +5,7 @@ use super::cold_start::{ }; use super::factory::{ConcentratedLiquidityFactory, FactoryConfig, PoolFactory}; use super::sim::{ - QuoteExactInputSingleParams, SimConfig, SimError, SwapQuote, quote_via_call, + QuoteExactInputSingleParams, SimConfig, SimError, SwapQuote, quote_via_call_from, quoteExactInputSingleCall, }; use super::{ @@ -245,7 +245,7 @@ impl AmmAdapter for ConcentratedLiquidityAdapter { }; let calldata = Bytes::from(quoteExactInputSingleCall { params }.abi_encode()); - let output = quote_via_call(cache, quoter, calldata)?; + let output = quote_via_call_from(cache, config.from, quoter, calldata)?; let decoded = quoteExactInputSingleCall::abi_decode_returns_validate(&output) .map_err(|_| SimError::MalformedOutput("quoteExactInputSingle return"))?; diff --git a/tests/facade_typing.rs b/tests/facade_typing.rs index 2c24076..3244187 100644 --- a/tests/facade_typing.rs +++ b/tests/facade_typing.rs @@ -23,6 +23,7 @@ use evm_amm_state::adapters::{ AdapterCache, AdapterDriver, AdapterEventError, AdapterEventResult, AdapterRegistry, AmmAdapter, CacheError, CallOutcome, DriverError, EventSource, PoolKey, PoolRegistration, ProtocolId, SimError, SlotChange, StateDiff, StateUpdate, StateView, quote_via_call, + quote_via_call_from, }; /// Minimal `AdapterCache` whose `call_raw` returns a caller-chosen `CallOutcome`. @@ -67,6 +68,52 @@ impl AdapterCache for MockCache { } } +/// Cache that records the `from` (`msg.sender`) its `call_raw` was invoked with, +/// so the quote helpers' sender threading can be asserted. +struct CapturingCache { + from: std::cell::Cell
, +} + +impl StateView for CapturingCache { + fn storage(&self, _address: Address, _slot: U256) -> Option { + None + } +} + +impl AdapterCache for CapturingCache { + fn cached_storage(&self, _address: Address, _slot: U256) -> Option { + None + } + fn apply_updates(&mut self, _updates: &[StateUpdate]) -> StateDiff { + StateDiff::default() + } + fn verify_slots(&mut self, _slots: &[(Address, U256)]) -> Result, CacheError> { + Ok(Vec::new()) + } + fn purge_storage(&mut self, _address: Address) -> StateDiff { + StateDiff::default() + } + fn purge_slots(&mut self, _address: Address, _slots: &[U256]) -> StateDiff { + StateDiff::default() + } + fn read_storage_slot(&mut self, _address: Address, _slot: U256) -> Result { + Ok(U256::ZERO) + } + fn call_raw( + &mut self, + from: Address, + _to: Address, + _calldata: Bytes, + _commit: bool, + ) -> Result { + self.from.set(from); + Ok(CallOutcome::Success { + output: Bytes::new(), + gas_used: 0, + }) + } +} + /// An adapter that always returns a structured decode error. struct ErringAdapter; @@ -111,6 +158,25 @@ fn quote_via_call_is_public_and_maps_revert_to_sim_error() { assert!(matches!(err, SimError::Reverted)); } +#[test] +fn quote_via_call_from_threads_the_sender() { + // `quote_via_call` runs the quote as the ZERO sender. + let mut cache = CapturingCache { + from: std::cell::Cell::new(Address::repeat_byte(0xee)), + }; + quote_via_call(&mut cache, Address::repeat_byte(0x01), Bytes::new()).unwrap(); + assert_eq!(cache.from.get(), Address::ZERO); + + // `quote_via_call_from` runs it as the given sender — this is what every + // adapter wires from `SimConfig::from`. + let sender = Address::repeat_byte(0x99); + let mut cache = CapturingCache { + from: std::cell::Cell::new(Address::ZERO), + }; + quote_via_call_from(&mut cache, sender, Address::repeat_byte(0x01), Bytes::new()).unwrap(); + assert_eq!(cache.from.get(), sender); +} + #[test] fn quote_via_call_returns_success_output() { let payload = Bytes::from(vec![0xaa, 0xbb]); From 7328919285923629298e599167f8214eb06805b8 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 13:06:22 +0100 Subject: [PATCH 4/9] hardening(tier-2): document the full public surface + #![warn(missing_docs)] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 2 of the Tier 2 polish PR. Enable `#![warn(missing_docs)]` at the crate root (CI's `-D warnings` promotes it to an error), and document every previously-undocumented public item so the surface stays fully documented as it grows — ~300 items across types, factory, bytecode, registry, cache, traits, storage, reactive, driver, the adapter structs, and the module declarations. Docs only; no behavior change. Verified: missing_docs = 0 under all-features / no-default / experimental; clippy -D warnings (all-features + no-default + isolation), doc -D warnings, fmt, and tests all green. Co-Authored-By: Claude Opus 4.8 --- docs/protocol-support-matrix.md | 2 +- src/adapters/balancer_v2.rs | 1 + src/adapters/bytecode.rs | 39 +++++++ src/adapters/cache.rs | 10 ++ src/adapters/driver.rs | 6 + src/adapters/factory.rs | 49 +++++++- src/adapters/mod.rs | 10 ++ src/adapters/reactive.rs | 4 + src/adapters/registry.rs | 22 ++++ src/adapters/storage.rs | 9 ++ src/adapters/traits.rs | 12 ++ src/adapters/types.rs | 200 +++++++++++++++++++++++++++++++- src/adapters/uniswap_v2.rs | 1 + src/lib.rs | 5 + 14 files changed, 364 insertions(+), 6 deletions(-) diff --git a/docs/protocol-support-matrix.md b/docs/protocol-support-matrix.md index cb33071..54d000d 100644 --- a/docs/protocol-support-matrix.md +++ b/docs/protocol-support-matrix.md @@ -15,7 +15,7 @@ backend or extra setup is still needed. This complements the summary table in th | **Slipstream / Aerodrome CL** (`slipstream`) | as Uniswap V3 (Slipstream slot layout) | ⚠️ **discovery + cold-start only** | as Uniswap V3 | ✅ tickSpacing-keyed `getPool[t0][t1][spacing]` | discovered `fee` is left unset (its quoter takes a different ABI); `simulate_swap` returns `MissingMetadata` unless the caller supplies a compatible quoter + fee | | **Balancer V2** (`balancer-v2`) | discover→verify (`getPoolTokens` read-set) | ✅ (the vault's code is lazily fetched on the first quote) | `Swap` → balance-slot **resync** | ❌ not shipped (no on-chain token→pool index; needs an async log scan) | register pools explicitly | | **Solidly V2** (`solidly-v2`) | named slots (config layout: reserves + tokens) | ⚠️ `getAmountOut` also reads the pool's `stable` flag + token `decimals` and STATICCALLs `factory.getFee()`, so the first offline quote lazily fetches those (and the factory code) unless a backend is attached or they are pre-warmed | `Sync` → **exact** two-slot write, no RPC | ✅ `getPool[t0][t1][bool stable]` (Aerodrome preset verified on Base) | Velodrome/Optimism reuses Aerodrome's constants — unverified on Optimism | -| **Curve** — StableSwap, StableSwap-NG, CryptoSwap v2, Tricrypto-NG (`curve`) | discover→verify (`get_dy` read-set) | ✅ fully offline | `TokenExchange` + liquidity events → discovered-slot **resync** | ❌ not shipped (needs the Vyper MetaRegistry view call) | metapools / lending pools out of scope (their `get_dy` makes external calls a pool-only capture misses) | +| **Curve** — StableSwap, StableSwap-NG, CryptoSwap v2, Tricrypto-NG (`curve`) | discover→verify (`get_dy` read-set) | ✅ (the pool's code is lazily fetched on the first quote) | `TokenExchange` + liquidity events → discovered-slot **resync** | ❌ not shipped (needs the Vyper MetaRegistry view call) | metapools / lending pools out of scope (their `get_dy` makes external calls a pool-only capture misses) | Legend: **exact** = the event carries absolute state, applied with no RPC; **resync** = the event carries only deltas, so the affected slots are re-verified diff --git a/src/adapters/balancer_v2.rs b/src/adapters/balancer_v2.rs index e9c8b0b..459696f 100644 --- a/src/adapters/balancer_v2.rs +++ b/src/adapters/balancer_v2.rs @@ -26,6 +26,7 @@ sol! { returns (address[] tokens, uint256[] balances, uint256 lastChangeBlock); } +/// Adapter for Balancer V2 (shared-vault) pools. #[derive(Clone, Debug, Default)] pub struct BalancerV2Adapter { _private: (), diff --git a/src/adapters/bytecode.rs b/src/adapters/bytecode.rs index 19415cf..ddc9a01 100644 --- a/src/adapters/bytecode.rs +++ b/src/adapters/bytecode.rs @@ -98,6 +98,7 @@ pub struct AdapterCodeSeed { } impl AdapterCodeSeed { + /// A seed for `address`, computing the code hash from `runtime_bytecode`. pub fn new(address: Address, runtime_bytecode: impl Into) -> Self { let runtime_bytecode = runtime_bytecode.into(); let code_hash = runtime_code_hash(&runtime_bytecode); @@ -108,6 +109,7 @@ impl AdapterCodeSeed { } } + /// A seed with a precomputed `code_hash` (skips re-hashing the bytecode). pub fn with_code_hash( address: Address, runtime_bytecode: impl Into, @@ -231,54 +233,72 @@ pub struct BytecodePatch { } impl BytecodePatch { + /// A patch covering `length` bytes at `offset` in the runtime bytecode. pub const fn new(offset: usize, length: usize) -> Self { Self { offset, length } } } /// Immutable byte ranges for a V3-style pool runtime template. +/// +/// Each field lists the byte ranges in the template occupied by that Solidity +/// immutable, patched per-pool at render time. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct V3ImmutablePatches { + /// Ranges holding the pool's own address (`NoDelegateCall` self-address). pub pool_address: &'static [BytecodePatch], + /// Ranges holding the factory/deployer address. pub factory: &'static [BytecodePatch], + /// Ranges holding `token0`. pub token0: &'static [BytecodePatch], + /// Ranges holding `token1`. pub token1: &'static [BytecodePatch], + /// Ranges holding the fee. pub fee: &'static [BytecodePatch], + /// Ranges holding the tick spacing. pub tick_spacing: &'static [BytecodePatch], + /// Ranges holding `maxLiquidityPerTick`. pub max_liquidity_per_tick: &'static [BytecodePatch], } impl V3ImmutablePatches { + /// Set the pool-address patch ranges. pub fn with_pool_address(mut self, patches: &'static [BytecodePatch]) -> Self { self.pool_address = patches; self } + /// Set the factory patch ranges. pub fn with_factory(mut self, patches: &'static [BytecodePatch]) -> Self { self.factory = patches; self } + /// Set the `token0` patch ranges. pub fn with_token0(mut self, patches: &'static [BytecodePatch]) -> Self { self.token0 = patches; self } + /// Set the `token1` patch ranges. pub fn with_token1(mut self, patches: &'static [BytecodePatch]) -> Self { self.token1 = patches; self } + /// Set the fee patch ranges. pub fn with_fee(mut self, patches: &'static [BytecodePatch]) -> Self { self.fee = patches; self } + /// Set the tick-spacing patch ranges. pub fn with_tick_spacing(mut self, patches: &'static [BytecodePatch]) -> Self { self.tick_spacing = patches; self } + /// Set the `maxLiquidityPerTick` patch ranges. pub fn with_max_liquidity_per_tick(mut self, patches: &'static [BytecodePatch]) -> Self { self.max_liquidity_per_tick = patches; self @@ -295,6 +315,7 @@ pub struct V3RuntimeBytecodeTemplate { } impl V3RuntimeBytecodeTemplate { + /// A template from `runtime_bytecode` and its immutable patch locations. pub fn new(runtime_bytecode: impl Into, immutables: V3ImmutablePatches) -> Self { Self { runtime_bytecode: runtime_bytecode.into(), @@ -352,28 +373,46 @@ impl V3RuntimeBytecodeTemplate { /// Per-pool immutable values used to render a V3 runtime bytecode template. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct V3ImmutablePatchValues { + /// The pool's own address. pub pool_address: Option
, + /// The factory/deployer address. pub factory: Option
, + /// The pool's `token0`. pub token0: Option
, + /// The pool's `token1`. pub token1: Option
, + /// The pool fee. pub fee: Option, + /// The pool's tick spacing. pub tick_spacing: Option, + /// The pool's `maxLiquidityPerTick`. pub max_liquidity_per_tick: Option, } +/// Why rendering a V3 runtime bytecode template failed. #[derive(Clone, Debug, PartialEq, Eq)] pub enum BytecodeTemplateError { + /// A patch range was declared for `field` but no value was supplied. MissingImmutable { + /// The immutable whose value was missing. field: &'static str, }, + /// A patch range's `length` cannot hold the immutable's encoded value. InvalidPatchLength { + /// The immutable being patched. field: &'static str, + /// The declared patch length that is too small. length: usize, }, + /// A patch range falls outside the template bytecode. PatchOutOfBounds { + /// The immutable being patched. field: &'static str, + /// The patch's byte offset. offset: usize, + /// The patch's byte length. length: usize, + /// The template bytecode's length. bytecode_len: usize, }, } diff --git a/src/adapters/cache.rs b/src/adapters/cache.rs index 91240c0..f06ad2a 100644 --- a/src/adapters/cache.rs +++ b/src/adapters/cache.rs @@ -114,16 +114,24 @@ impl From for CacheError { /// Cache facade used by protocol adapters. pub trait AdapterCache: StateView { + /// The cached value of `slot` at `address`, or `None` if not warmed. fn cached_storage(&self, address: Address, slot: U256) -> Option; + /// Apply state updates to the cache, returning the resulting diff (including + /// any updates skipped because their base slot was cold). fn apply_updates(&mut self, updates: &[StateUpdate]) -> StateDiff; + /// Authoritatively re-fetch the given slots and inject any that changed, + /// returning the changes. fn verify_slots(&mut self, slots: &[(Address, U256)]) -> Result, CacheError>; + /// Invalidate all cached storage for `address`, returning the diff. fn purge_storage(&mut self, address: Address) -> StateDiff; + /// Invalidate the given `slots` of `address`, returning the diff. fn purge_slots(&mut self, address: Address, slots: &[U256]) -> StateDiff; + /// Read one storage slot (from cache, or lazily from the backend). fn read_storage_slot(&mut self, address: Address, slot: U256) -> Result; /// Read many storage slots, returning one value per input slot in the SAME @@ -137,6 +145,8 @@ pub trait AdapterCache: StateView { .collect() } + /// Execute a raw EVM call against the cached state. `commit = false` runs it + /// read-only (the quote path); `true` persists the resulting state changes. fn call_raw( &mut self, from: Address, diff --git a/src/adapters/driver.rs b/src/adapters/driver.rs index 008c036..5d4a5c7 100644 --- a/src/adapters/driver.rs +++ b/src/adapters/driver.rs @@ -49,18 +49,24 @@ pub struct AdapterDriver { } impl AdapterDriver { + /// A driver over `registry`. pub fn new(registry: AdapterRegistry) -> Self { Self { registry } } + /// Borrow the underlying registry. pub fn registry(&self) -> &AdapterRegistry { &self.registry } + /// Consume the driver, returning its registry. pub fn into_registry(self) -> AdapterRegistry { self.registry } + /// Route and apply a single log, returning its report (`None` if unrouted). + /// Returns a [`DriverError`] for a routed-but-malformed log; use + /// [`apply_logs`](Self::apply_logs) for batch-robust application. pub fn apply_log( &self, cache: &mut C, diff --git a/src/adapters/factory.rs b/src/adapters/factory.rs index c7fd722..6200d7f 100644 --- a/src/adapters/factory.rs +++ b/src/adapters/factory.rs @@ -452,6 +452,7 @@ impl PoolQuery { #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub struct FactoryConfig { + /// Uniswap V2 factories (canonical plus same-protocol forks). #[cfg(feature = "uniswap-v2")] pub uniswap_v2: Vec, /// Concentrated-liquidity forks (Uniswap V3, SushiSwap V3, PancakeSwap V3, @@ -465,6 +466,8 @@ pub struct FactoryConfig { /// per config. #[cfg(feature = "solidly-v2")] pub solidly: Vec, + /// Global switch for the CREATE2 cross-check: a spec's derivation + /// verification runs only when both this and the spec opt in. Defaults `true`. pub verify_derivations: bool, } @@ -483,12 +486,14 @@ impl Default for FactoryConfig { } impl FactoryConfig { + /// Add a Uniswap V2 factory by its [`UniswapV2FactoryConfig`]. #[cfg(feature = "uniswap-v2")] pub fn with_uniswap_v2(mut self, config: UniswapV2FactoryConfig) -> Self { self.uniswap_v2.push(config); self } + /// Add a canonical Uniswap V2 factory at `factory`. #[cfg(feature = "uniswap-v2")] pub fn with_uniswap_v2_factory(self, factory: Address) -> Self { self.with_uniswap_v2(UniswapV2FactoryConfig::uniswap_v2(factory)) @@ -544,24 +549,31 @@ impl FactoryConfig { self.with_solidly(SolidlyFactoryConfig::aerodrome(factory)) } + /// Toggle the global CREATE2 derivation cross-check (default `true`). pub fn with_verify_derivations(mut self, verify_derivations: bool) -> Self { self.verify_derivations = verify_derivations; self } } +/// Configuration for one Uniswap V2 (or V2-fork) factory. #[cfg(feature = "uniswap-v2")] #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub struct UniswapV2FactoryConfig { + /// The factory contract address. pub factory: Address, + /// Base storage slot of the factory's `getPair[t0][t1]` mapping. pub get_pair_base_slot: U256, + /// Optional pair init-code hash for the CREATE2 cross-check. pub init_code_hash: Option, + /// Optional swap fee (basis points) carried into discovered pool metadata. pub fee_bps: Option, } #[cfg(feature = "uniswap-v2")] impl UniswapV2FactoryConfig { + /// A canonical Uniswap V2 factory preset at `factory`. pub fn uniswap_v2(factory: Address) -> Self { Self { factory, @@ -571,16 +583,19 @@ impl UniswapV2FactoryConfig { } } + /// Override the `getPair` mapping base slot (for a non-canonical fork). pub fn with_get_pair_base_slot(mut self, slot: U256) -> Self { self.get_pair_base_slot = slot; self } + /// Set the pair init-code hash (enables the CREATE2 cross-check). pub fn with_init_code_hash(mut self, hash: B256) -> Self { self.init_code_hash = Some(hash); self } + /// Set the swap fee (basis points) for discovered pools. pub fn with_fee_bps(mut self, fee_bps: u32) -> Self { self.fee_bps = Some(fee_bps); self @@ -1051,9 +1066,13 @@ impl SolidlyFactoryConfig { #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub enum DiscoverySource { + /// Resolved by a factory-storage / view query (a [`PoolQuery`]). Query, + /// Decoded from a factory creation log. CreationEvent { + /// The creation log's block number, if known. block_number: Option, + /// The creation log's index within the block, if known. log_index: Option, }, } @@ -1062,11 +1081,14 @@ pub enum DiscoverySource { #[non_exhaustive] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct CreationLogContext { + /// The log's block number, if known. pub block_number: Option, + /// The log's index within the block, if known. pub log_index: Option, } impl CreationLogContext { + /// A context from an optional block number and log index. pub const fn new(block_number: Option, log_index: Option) -> Self { Self { block_number, @@ -1084,8 +1106,11 @@ impl CreationLogContext { #[derive(Clone, Debug)] #[non_exhaustive] pub struct DiscoveredPool { + /// The discovered pool's key. pub key: PoolKey, + /// A cold-start-ready registration for the pool. pub registration: PoolRegistration, + /// How the pool was discovered. pub source: DiscoverySource, } @@ -1104,10 +1129,19 @@ impl DiscoveredPool { #[derive(Debug)] #[non_exhaustive] pub enum DiscoveryError { + /// A `.on(protocol)`-scoped query named a protocol with no configured factory. MissingFactory(ProtocolId), + /// The factory query (storage read / view call) failed. Factory(Box), + /// A factory response could not be decoded. Malformed(&'static str), - DerivationMismatch { mapping: Address, derived: Address }, + /// The factory mapping answer disagrees with the CREATE2 derivation. + DerivationMismatch { + /// The pool address the factory mapping returned. + mapping: Address, + /// The pool address derived from CREATE2 (init-code hash + salt). + derived: Address, + }, } impl fmt::Display for DiscoveryError { @@ -1141,6 +1175,7 @@ impl From for DiscoveryError { /// Per-protocol factory driver. pub trait PoolFactory: Send + Sync { + /// The protocol whose pools this factory resolves. fn protocol(&self) -> ProtocolId; /// Address of the factory contract this driver resolves against. Used @@ -1198,8 +1233,12 @@ pub trait PoolFactory: Send + Sync { Ok(Vec::new()) } + /// The log sources (factory address + creation topic) to subscribe for + /// live pool-creation discovery. fn creation_sources(&self) -> Vec; + /// Decode a factory creation log into a discovered pool, or `None` if the + /// log is not a creation event this factory handles. fn decode_creation( &self, log: &Log, @@ -1220,6 +1259,8 @@ pub struct PoolDiscovery { } impl PoolDiscovery { + /// A discovery front-end over an explicit set of factory drivers + /// (de-duplicated by `(protocol, factory_address)`, insertion order kept). pub fn new(factories: impl IntoIterator>) -> Self { let mut discovery = Self { factories: Vec::new(), @@ -1230,6 +1271,8 @@ impl PoolDiscovery { discovery } + /// Build discovery by fanning `config` out to every registered adapter's + /// `pool_factories`, collecting the resulting drivers. pub fn for_registry(registry: &AdapterRegistry, config: FactoryConfig) -> Self { let mut factories = Vec::new(); let mut seen = Vec::new(); @@ -1390,6 +1433,8 @@ impl PoolDiscovery { self.find_many(cache, std::iter::once(query)) } + /// The union of every registered factory's creation-log sources, for live + /// pool-creation discovery. pub fn creation_sources(&self) -> Vec { self.factories .iter() @@ -1397,6 +1442,8 @@ impl PoolDiscovery { .collect() } + /// Decode a factory creation `log` into a discovered pool by trying each + /// registered factory; `Ok(None)` if none handled it. pub fn decode_creation( &self, log: &Log, diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 706d6e5..b94e01b 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -5,11 +5,15 @@ // Protocol-neutral infrastructure — always compiled (no heavy deps). pub mod bytecode; +/// The [`AdapterCache`] facade over `evm-fork-cache` (reads, writes, raw calls). pub mod cache; pub mod cold_start; +/// [`AdapterDriver`], which applies decoded logs to a cache in caller order. pub mod driver; pub mod factory; +/// The [`AmmReactiveHandler`] bridge onto the `evm-fork-cache` reactive runtime. pub mod reactive; +/// The [`AdapterRegistry`] of tracked pools and protocol adapters. pub mod registry; pub mod repair; pub mod sim; @@ -17,18 +21,24 @@ pub mod state; pub mod storage; pub mod storage_sync; pub mod sync_manager; +/// The [`AmmAdapter`] protocol-adapter trait. pub mod traits; +/// Core public vocabulary: pool keys, metadata, events, repairs, and outcomes. pub mod types; // Per-protocol adapters — gated by their protocol feature. +/// Balancer V2 adapter (shared-vault `queryBatchSwap` quotes). #[cfg(feature = "balancer-v2")] pub mod balancer_v2; #[cfg(feature = "curve")] pub mod curve; +/// Solidly V2 (Aerodrome / Velodrome) adapter. #[cfg(feature = "solidly-v2")] pub mod solidly_v2; +/// Uniswap V2 adapter (constant-product pairs). #[cfg(feature = "uniswap-v2")] pub mod uniswap_v2; +/// Uniswap V3-family adapter (Uniswap V3 / PancakeSwap V3 / Slipstream). #[cfg(feature = "uniswap-v3")] pub mod uniswap_v3; #[cfg(feature = "uniswap-v3")] diff --git a/src/adapters/reactive.rs b/src/adapters/reactive.rs index d0c31c8..f2d81e7 100644 --- a/src/adapters/reactive.rs +++ b/src/adapters/reactive.rs @@ -27,14 +27,17 @@ pub struct AmmReactiveHandler { } impl AmmReactiveHandler { + /// Wrap an [`AdapterRegistry`] as a reactive handler. pub fn new(registry: AdapterRegistry) -> Self { Self { registry } } + /// This handler's stable id in the reactive runtime. pub fn id(&self) -> HandlerId { HandlerId::new(HANDLER_ID) } + /// The log interests (emitter/topic filters) for every tracked pool. pub fn interests(&self) -> Vec> { self.registry .pools() @@ -43,6 +46,7 @@ impl AmmReactiveHandler { .collect() } + /// The wrapped registry. pub fn registry(&self) -> &AdapterRegistry { &self.registry } diff --git a/src/adapters/registry.rs b/src/adapters/registry.rs index dcbf521..f0ca2d8 100644 --- a/src/adapters/registry.rs +++ b/src/adapters/registry.rs @@ -31,6 +31,7 @@ impl Default for AdapterRegistry { } impl AdapterRegistry { + /// An empty registry with code-seeding enabled. pub fn new() -> Self { Self::default() } @@ -46,6 +47,8 @@ impl AdapterRegistry { self } + /// Register a pool. Errors [`RegistryError::DuplicatePool`] if its key is + /// already registered. pub fn register_pool(&mut self, registration: PoolRegistration) -> Result<(), RegistryError> { if self.pools.contains_key(®istration.key) { return Err(RegistryError::DuplicatePool(registration.key)); @@ -65,6 +68,9 @@ impl AdapterRegistry { self.pools.remove(key) } + /// Register an adapter under every id it [`serves`](AmmAdapter::protocols). + /// Errors [`RegistryError::DuplicateAdapter`] if any of those ids is taken + /// (no partial insert). pub fn register_adapter(&mut self, adapter: Arc) -> Result<(), RegistryError> { // Validate every claimed id up front so a multi-protocol adapter never // partially inserts when one of its ids collides. @@ -113,26 +119,33 @@ impl AdapterRegistry { Ok(Some(adapter)) } + /// The adapter registered for `protocol`, if any. pub fn adapter(&self, protocol: ProtocolId) -> Option<&Arc> { self.adapters.get(&protocol) } + /// Iterate the registered adapters (a family adapter appears once per id). pub fn adapters(&self) -> impl Iterator> { self.adapters.values() } + /// The registration for `key`, if tracked. pub fn pool(&self, key: &PoolKey) -> Option<&PoolRegistration> { self.pools.get(key) } + /// A mutable borrow of the registration for `key`, if tracked. pub fn pool_mut(&mut self, key: &PoolKey) -> Option<&mut PoolRegistration> { self.pools.get_mut(key) } + /// Iterate the tracked pool registrations. pub fn pools(&self) -> impl Iterator { self.pools.values() } + /// Route `log` to the pool it belongs to (generic emitter/topic routing, + /// then each adapter's `route_log` fallback). pub fn route_log(&self, log: &Log) -> Option<&PoolRegistration> { if let Some(pool) = self.route_log_generic(log) { return Some(pool); @@ -167,6 +180,8 @@ impl AdapterRegistry { }) } + /// The sorted, de-duplicated set of `topic0`s across every tracked pool's + /// event sources (a log-subscription filter). pub fn subscription_topics(&self) -> Vec { let mut topics: Vec = self .pools @@ -180,6 +195,7 @@ impl AdapterRegistry { topics } + /// The full [`SubscriptionSpec`] (every tracked pool's event sources). pub fn subscription_spec(&self) -> SubscriptionSpec { SubscriptionSpec { sources: self @@ -190,10 +206,12 @@ impl AdapterRegistry { } } + /// The number of tracked pools. pub fn len(&self) -> usize { self.pools.len() } + /// Whether no pools are tracked. pub fn is_empty(&self) -> bool { self.pools.is_empty() } @@ -254,9 +272,11 @@ impl fmt::Debug for AdapterRegistry { } } +/// The set of event sources to subscribe for a registry's tracked pools. #[non_exhaustive] #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct SubscriptionSpec { + /// Every event source to subscribe across the tracked pools. pub sources: Vec, } @@ -307,7 +327,9 @@ fn topic_address(topic: &B256) -> Address { #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub enum RegistryError { + /// A pool with this key is already registered. DuplicatePool(PoolKey), + /// An adapter for this protocol id is already registered. DuplicateAdapter(ProtocolId), /// The adapter still serves at least one registered pool and cannot be /// unregistered until those pools are removed. diff --git a/src/adapters/storage.rs b/src/adapters/storage.rs index 153522a..76e9be2 100644 --- a/src/adapters/storage.rs +++ b/src/adapters/storage.rs @@ -55,10 +55,15 @@ pub const SLIPSTREAM_TICK_BITMAP_BASE_SLOT: U256 = U256::from_limbs([18, 0, 0, 0 #[non_exhaustive] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct V3StorageLayout { + /// Storage slot of packed `slot0` (sqrtPriceX96 + current tick). pub slot0_slot: U256, + /// Storage slot of the global `liquidity`. pub liquidity_slot: U256, + /// Base slot of the `ticks` mapping (`Tick.Info` structs). pub ticks_base_slot: U256, + /// Base slot of the `tickBitmap` mapping. pub tick_bitmap_base_slot: U256, + /// The pool's tick spacing (must be positive). pub tick_spacing: i32, } @@ -126,9 +131,13 @@ impl V3StorageLayout { #[non_exhaustive] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct SolidlyStorageLayout { + /// Storage slot of `reserve0` (a full `uint256` word). pub reserve0_slot: U256, + /// Storage slot of `reserve1` (a full `uint256` word). pub reserve1_slot: U256, + /// Storage slot of the `token0` address. pub token0_slot: U256, + /// Storage slot of the `token1` address. pub token1_slot: U256, } diff --git a/src/adapters/traits.rs b/src/adapters/traits.rs index 22359c6..896abd4 100644 --- a/src/adapters/traits.rs +++ b/src/adapters/traits.rs @@ -11,6 +11,7 @@ use super::{ /// Protocol adapter contract for AMM-specific routing, cold-start, and decoding. pub trait AmmAdapter: Send + Sync { + /// The adapter's primary/canonical protocol id. fn protocol(&self) -> ProtocolId; /// Every protocol id this adapter serves. @@ -24,10 +25,14 @@ pub trait AmmAdapter: Send + Sync { vec![self.protocol()] } + /// The log sources to subscribe/route for `pool`. Defaults to the pool's own + /// stored `event_sources`; override to derive them from adapter knowledge. fn event_sources(&self, pool: &PoolRegistration) -> Vec { pool.event_sources.clone() } + /// Route a log to the pool key it belongs to. Defaults to the registry's + /// generic emitter/topic routing; override for adapter-defined routing. fn route_log(&self, log: &Log, registry: &AdapterRegistry) -> Option { registry.route_log_generic(log).map(|pool| pool.key.clone()) } @@ -89,6 +94,10 @@ pub trait AmmAdapter: Send + Sync { Ok(Vec::new()) } + /// Decode a routed log into a semantic event with its cache updates. + /// Defaults to [`AdapterEventResult::ignored`]; a malformed watched log + /// should return [`AdapterEventResult::error`] (it is isolated per-log, not + /// batch-fatal), never panic. fn decode_event( &self, _pool: &PoolRegistration, @@ -98,6 +107,9 @@ pub trait AmmAdapter: Send + Sync { AdapterEventResult::ignored() } + /// Follow-up repair after `event`'s updates were applied, given the resulting + /// `diff` (e.g. re-verify slots that were skipped because they were cold). + /// Defaults to [`RepairAction::None`]. fn after_apply( &self, _pool: &PoolRegistration, diff --git a/src/adapters/types.rs b/src/adapters/types.rs index bb2a5a7..fee1555 100644 --- a/src/adapters/types.rs +++ b/src/adapters/types.rs @@ -11,19 +11,30 @@ use super::storage::{SolidlyStorageLayout, V3StorageLayout}; #[non_exhaustive] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum ProtocolId { + /// Uniswap V2 constant-product pairs. UniswapV2, + /// Uniswap V3 concentrated-liquidity pools. UniswapV3, + /// PancakeSwap V3 (Uniswap V3-family with its own fee tiers / slot layout). PancakeV3, + /// Slipstream / Aerodrome concentrated-liquidity (tickSpacing-keyed). Slipstream, + /// Solidly V2 (Aerodrome / Velodrome) reserves pools. SolidlyV2, + /// Balancer V2 (shared-vault) pools. BalancerV2, + /// Balancer V3 — reserved identity, no adapter yet. #[cfg(feature = "experimental-protocols")] BalancerV3, + /// Curve StableSwap / CryptoSwap family pools. Curve, + /// ERC-4626 tokenized vaults — reserved identity, no adapter yet. #[cfg(feature = "experimental-protocols")] Erc4626, + /// Uniswap V4 — reserved identity, no adapter yet. #[cfg(feature = "experimental-protocols")] UniswapV4, + /// A third-party protocol, identified by a `'static` name. Custom(&'static str), } @@ -31,19 +42,30 @@ pub enum ProtocolId { #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum PoolKey { + /// Uniswap V2 pair, keyed by pool address. UniswapV2(Address), + /// Uniswap V3 pool, keyed by pool address. UniswapV3(Address), + /// PancakeSwap V3 pool, keyed by pool address. PancakeV3(Address), + /// Slipstream / Aerodrome CL pool, keyed by pool address. Slipstream(Address), + /// Solidly V2 pool, keyed by pool address. SolidlyV2(Address), + /// Balancer V2 pool, keyed by its 32-byte `poolId`. BalancerV2(B256), + /// Balancer V3 pool, keyed by pool address (reserved; no adapter yet). #[cfg(feature = "experimental-protocols")] BalancerV3(Address), + /// Curve pool, keyed by pool address. Curve(Address), + /// ERC-4626 vault, keyed by address (reserved; no adapter yet). #[cfg(feature = "experimental-protocols")] Erc4626(Address), + /// Uniswap V4 pool, keyed by its 32-byte pool id (reserved; no adapter yet). #[cfg(feature = "experimental-protocols")] UniswapV4(B256), + /// A third-party pool identity (see [`CustomPoolKey`]). Custom(CustomPoolKey), } @@ -109,22 +131,33 @@ impl PoolKey { #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum CustomPoolKey { + /// An address-keyed custom pool. Address { + /// The custom protocol's `'static` name. protocol: &'static str, + /// The pool's contract address. address: Address, }, + /// A bytes32-keyed custom pool (e.g. a vault-style pool id). Bytes32 { + /// The custom protocol's `'static` name. protocol: &'static str, + /// The pool's 32-byte identifier. id: B256, }, + /// A custom pool identified by both an address and a bytes32 id. Composite { + /// The custom protocol's `'static` name. protocol: &'static str, + /// The pool's contract address. address: Address, + /// The pool's 32-byte identifier. id: B256, }, } impl CustomPoolKey { + /// The [`ProtocolId::Custom`] this key belongs to. pub fn protocol(&self) -> ProtocolId { match self { Self::Address { protocol, .. } @@ -133,6 +166,7 @@ impl CustomPoolKey { } } + /// The pool's contract address, for address- or composite-keyed variants. pub fn address(&self) -> Option
{ match self { Self::Address { address, .. } | Self::Composite { address, .. } => Some(*address), @@ -140,6 +174,7 @@ impl CustomPoolKey { } } + /// The pool's 32-byte id, for bytes32- or composite-keyed variants. pub fn bytes32(&self) -> Option { match self { Self::Bytes32 { id, .. } | Self::Composite { id, .. } => Some(*id), @@ -152,12 +187,16 @@ impl CustomPoolKey { #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub struct EventSource { + /// The contract address that emits the log. pub emitter: Address, + /// The `topic0` signature hashes this source matches (empty = any topic). pub topics: Vec, + /// How a matched log is routed to a pool key. pub route: EventRoute, } impl EventSource { + /// A source whose logs route directly by emitter address. pub fn direct(emitter: Address, topics: Vec) -> Self { Self { emitter, @@ -166,6 +205,7 @@ impl EventSource { } } + /// A source whose logs route by an indexed **address** topic at `topic_index`. pub fn indexed_address(emitter: Address, topics: Vec, topic_index: usize) -> Self { Self { emitter, @@ -174,6 +214,7 @@ impl EventSource { } } + /// A source whose logs route by an indexed **bytes32** topic at `topic_index`. pub fn indexed_bytes32(emitter: Address, topics: Vec, topic_index: usize) -> Self { Self { emitter, @@ -182,6 +223,7 @@ impl EventSource { } } + /// A source whose routing is decided by the adapter's own `route_log`. pub fn adapter_defined(emitter: Address, topics: Vec) -> Self { Self { emitter, @@ -198,9 +240,19 @@ impl EventSource { /// dispatch semantics and warrants a breaking release. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum EventRoute { + /// The log belongs to the pool whose key address is the emitter. Direct, - IndexedAddress { topic_index: usize }, - IndexedBytes32 { topic_index: usize }, + /// Route by an indexed address topic at `topic_index` (the low 20 bytes). + IndexedAddress { + /// Index of the topic carrying the pool address. + topic_index: usize, + }, + /// Route by an indexed bytes32 topic at `topic_index` (e.g. a Balancer poolId). + IndexedBytes32 { + /// Index of the topic carrying the pool's bytes32 id. + topic_index: usize, + }, + /// Routing is delegated to the adapter's own `route_log`. AdapterDefined, } @@ -208,14 +260,21 @@ pub enum EventRoute { #[non_exhaustive] #[derive(Clone, Debug)] pub struct PoolRegistration { + /// The pool's protocol-specific identity. pub key: PoolKey, + /// Contract addresses whose storage backs this pool (pool and/or vault). pub state_addresses: Vec
, + /// Log sources to subscribe and route for this pool. pub event_sources: Vec, + /// Protocol metadata (tokens, fee, layout, discovered slots, …). pub metadata: ProtocolMetadata, + /// Lifecycle status of the registration. pub status: PoolStatus, } impl PoolRegistration { + /// A new registration for `key` with empty sources/metadata and + /// [`PoolStatus::Pending`]. pub fn new(key: PoolKey) -> Self { Self { key, @@ -226,35 +285,42 @@ impl PoolRegistration { } } + /// The pool's protocol family (from its [`key`](Self::key)). pub fn protocol(&self) -> ProtocolId { self.key.protocol() } + /// Add one backing state address. pub fn with_state_address(mut self, address: Address) -> Self { self.state_addresses.push(address); self } + /// Add several backing state addresses. pub fn with_state_addresses(mut self, addresses: impl IntoIterator) -> Self { self.state_addresses.extend(addresses); self } + /// Add one event source. pub fn with_event_source(mut self, source: EventSource) -> Self { self.event_sources.push(source); self } + /// Add several event sources. pub fn with_event_sources(mut self, sources: impl IntoIterator) -> Self { self.event_sources.extend(sources); self } + /// Set the protocol metadata. pub fn with_metadata(mut self, metadata: ProtocolMetadata) -> Self { self.metadata = metadata; self } + /// Set the lifecycle status. pub fn with_status(mut self, status: PoolStatus) -> Self { self.status = status; self @@ -265,15 +331,24 @@ impl PoolRegistration { #[non_exhaustive] #[derive(Clone, Default)] pub enum ProtocolMetadata { + /// No metadata known yet (the default before cold-start/registration fills it). #[default] Unknown, + /// Uniswap V2 pair metadata. UniswapV2(UniswapV2Metadata), + /// Uniswap V3 pool metadata. UniswapV3(V3Metadata), + /// PancakeSwap V3 pool metadata (shares [`V3Metadata`]). PancakeV3(V3Metadata), + /// Slipstream / Aerodrome CL pool metadata (shares [`V3Metadata`]). Slipstream(V3Metadata), + /// Balancer V2 pool metadata. BalancerV2(BalancerV2Metadata), + /// Solidly V2 pool metadata. SolidlyV2(SolidlyV2Metadata), + /// Curve pool metadata. Curve(CurveMetadata), + /// Opaque third-party metadata, downcast by the custom adapter. Custom(Arc), } @@ -293,11 +368,15 @@ impl fmt::Debug for ProtocolMetadata { } } +/// Metadata for a Uniswap V2 pair. #[non_exhaustive] #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct UniswapV2Metadata { + /// The pair's `token0` (decoded from storage at cold-start when unset). pub token0: Option
, + /// The pair's `token1` (decoded from storage at cold-start when unset). pub token1: Option
, + /// Config-supplied swap fee in basis points (V2 has no on-chain fee slot). pub fee_bps: Option, } @@ -321,12 +400,19 @@ impl UniswapV2Metadata { } } +/// Metadata for a Uniswap V3-family pool (Uniswap V3 / PancakeSwap V3 / Slipstream). #[non_exhaustive] #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct V3Metadata { + /// The pool's `token0`. pub token0: Option
, + /// The pool's `token1`. pub token1: Option
, + /// The pool fee in hundredths of a bip (e.g. `500` = 0.05%). Required for + /// `simulate_swap` (the QuoterV2 fee argument). pub fee: Option, + /// The pool's tick spacing (drives the derived storage layout when no + /// explicit `storage_layout` is set). pub tick_spacing: Option, /// Factory/deployer address embedded as an immutable in canonical Uniswap V3 /// pool bytecode. Factory discovery fills this; manual registrations can set @@ -339,6 +425,8 @@ pub struct V3Metadata { /// caller's configured quoter. Factory discovery fills this from the /// fork's [`ClFactorySpec`](super::factory::ClFactorySpec) quoter. pub quoter: Option
, + /// Explicit V3 storage layout (slot bases + tick spacing). When unset it is + /// derived from `tick_spacing` per the pool's family. pub storage_layout: Option, /// The ± radius, in tick-bitmap words, of the cold-start tick-warm window /// around the current word (`Strict`/`Eager` policies). @@ -400,14 +488,18 @@ impl V3Metadata { } } +/// Metadata for a Solidly V2 (Aerodrome / Velodrome) reserves pool. #[non_exhaustive] #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct SolidlyV2Metadata { + /// The pool's `token0` (decoded from the config layout at cold-start). pub token0: Option
, + /// The pool's `token1` (decoded from the config layout at cold-start). pub token1: Option
, /// `true` for stable (x³y+y³x) pools, `false` for volatile (xy=k). Config- /// supplied; preserved across cold-start. pub stable: Option, + /// Fork-specific reserve/token storage layout (config-supplied; no default). pub storage_layout: Option, } @@ -430,18 +522,22 @@ impl SolidlyV2Metadata { self } - /// Set the pool's Solidly storage layout descriptor. + /// Set the pool's Solidly storage layout descriptor (fork-specific slots). pub fn with_storage_layout(mut self, storage_layout: SolidlyStorageLayout) -> Self { self.storage_layout = Some(storage_layout); self } } +/// Metadata for a Balancer V2 pool. #[non_exhaustive] #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct BalancerV2Metadata { + /// The Balancer `Vault` address (the swap/quote target). pub vault: Option
, + /// The pool's own contract address (distinct from the shared vault). pub pool_address: Option
, + /// The pool's registered token list (from `getPoolTokens`). pub tokens: Vec
, /// Vault balance storage slots discovered during cold-start (the `(vault, /// slot)` pairs the `getPoolTokens` view-call SLOADed; recorded slot-only @@ -517,8 +613,12 @@ pub enum CurveVariant { #[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. pub discovered_slots: Vec, + /// The pool dialect selecting the `get_dy` / `TokenExchange` index ABI. pub variant: CurveVariant, } @@ -549,12 +649,18 @@ impl CurveMetadata { #[non_exhaustive] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub enum PoolStatus { + /// Registered but not yet cold-started. #[default] Pending, + /// Cold-start in progress / partially warmed. Cold, + /// Warmed and ready to simulate. Ready, + /// Warmed but a repair target failed; state may be stale until a resync. Degraded, + /// Explicitly disabled by the caller. Disabled, + /// The protocol/layout is not supported for this pool. Unsupported, } @@ -562,12 +668,19 @@ pub enum PoolStatus { #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub struct AdapterEvent { + /// The pool this event belongs to. pub pool: PoolKey, + /// The log's emitter address. pub emitter: Address, + /// The log's `topic0` signature hash. pub topic0: B256, + /// The high-level event class. pub kind: AdapterEventKind, + /// Cache mutations this event applies. pub updates: Vec, + /// Quality of the emitted updates (exact vs. needs-repair). pub quality: UpdateQuality, + /// Follow-up repair action to combine after applying `updates`. pub repair: RepairAction, } @@ -610,9 +723,13 @@ impl AdapterEvent { #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub struct AdapterEventReport { + /// The pool the event routed to. pub pool: PoolKey, + /// The decoded semantic event. pub event: AdapterEvent, + /// The diff actually applied to the cache. pub applied: StateDiff, + /// The combined follow-up repair (event repair + `after_apply`). pub repair: RepairAction, } @@ -620,12 +737,19 @@ pub struct AdapterEventReport { #[non_exhaustive] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum AdapterEventKind { + /// A swap (trade) event. Swap, + /// Liquidity added (mint / add_liquidity). LiquidityAdded, + /// Liquidity removed (burn / remove_liquidity). LiquidityRemoved, + /// A reserves-sync event carrying absolute state (Uniswap V2 / Solidly). Sync, + /// A deposit into a vault-style pool. Deposit, + /// A withdrawal from a vault-style pool. Withdraw, + /// An event the adapter recognized but does not classify further. Unknown, } @@ -633,11 +757,14 @@ pub enum AdapterEventKind { #[non_exhaustive] #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct AdapterEventResult { + /// The decoded event, if the log was recognized and well-formed. pub event: Option, + /// A structured decode error, if the log was recognized but malformed. pub error: Option, } impl AdapterEventResult { + /// A successful decode carrying `event`. pub fn event(event: AdapterEvent) -> Self { Self { event: Some(event), @@ -645,10 +772,12 @@ impl AdapterEventResult { } } + /// The log was not for this adapter/pool — neither event nor error. pub fn ignored() -> Self { Self::default() } + /// A recognized-but-malformed log carrying a structured `error`. pub fn error(error: AdapterEventError) -> Self { Self { event: None, @@ -661,9 +790,18 @@ impl AdapterEventResult { #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub enum AdapterEventError { + /// The log matched a watched topic but its payload could not be decoded. MalformedLog(&'static str), - MissingState { address: Address, slot: U256 }, + /// Decoding needed cached state that was absent at `address`/`slot`. + MissingState { + /// The contract whose slot was needed. + address: Address, + /// The storage slot that was needed. + slot: U256, + }, + /// The event or its routing is unsupported for this adapter. Unsupported(UnsupportedReason), + /// A protocol-specific decode failure. Custom(String), } @@ -689,10 +827,16 @@ impl std::error::Error for AdapterEventError {} /// what callers must handle and warrants a breaking release. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum UpdateQuality { + /// The updates are exact and unconditional. Exact, + /// Exact **if** applied — some updates may be skipped on cold slots, in + /// which case a resync follows. ExactIfApplied, + /// The event carries deltas; the affected slots need a repair/resync. RequiresRepair, + /// State could not be updated precisely; conservatively invalidate. ConservativeInvalidation, + /// The event produced no state effect. Ignored, } @@ -700,27 +844,45 @@ pub enum UpdateQuality { #[non_exhaustive] #[derive(Clone, Debug, Default, PartialEq, Eq)] pub enum RepairAction { + /// No follow-up needed. #[default] None, + /// Re-verify (resync) the listed `(address, slot)` pairs. VerifySlots(Vec<(Address, U256)>), + /// Invalidate all cached storage for an address. PurgeStorage(Address), + /// Invalidate specific slots of an address. PurgeSlots { + /// The contract whose slots to purge. address: Address, + /// The slots to purge. slots: Vec, }, + /// Re-run cold-start for a pool under `policy` (a caller-side escalation). ColdStart { + /// The pool to cold-start. pool: PoolKey, + /// The policy to cold-start it under. policy: ColdStartPolicy, }, + /// Resync the storage a V3 liquidity event over `[tick_lower, tick_upper]` + /// can dirty (boundary tick info, bitmap words, global liquidity). V3TickRange { + /// The V3 pool. pool: PoolKey, + /// The lower boundary tick of the liquidity range. tick_lower: i32, + /// The upper boundary tick of the liquidity range. tick_upper: i32, }, + /// Escalation signal: an incremental V3 re-warm is warranted (hook-only). V3Incremental { + /// The V3 pool. pool: PoolKey, }, + /// Escalation signal: a full V3 re-warm is warranted (hook-only). V3Full { + /// The V3 pool. pool: PoolKey, }, } @@ -775,9 +937,15 @@ impl RepairAction { /// change to all adapters and warrants a breaking release. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum ColdStartPolicy { + /// Warm the full read-set. Currently identical to `Eager` (no adapter + /// branches the two); reserved as a distinct policy for stricter future + /// miss handling. Strict, + /// Warm the full read-set — the common default. Eager, + /// Warm only the hot slots now and defer the rest as [`DeferredWork`]. Lazy, + /// Warm only the minimal hot slots (e.g. slot0 + liquidity), no tick warming. HotSlotsOnly, } @@ -785,9 +953,13 @@ pub enum ColdStartPolicy { #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub enum ColdStartOutcome { + /// Fully warmed and ready to simulate. Ready(ColdStartReport), + /// Warmed enough to be ready, with `DeferredWork` left to run later (`Lazy`). ReadyWithDeferred(ColdStartReport, Vec), + /// Warmed but a mandatory slot needs repair (e.g. an archive miss). NeedsRepair(ColdStartReport, RepairAction), + /// The pool/protocol/layout is not supported. Unsupported(UnsupportedReason), } @@ -795,12 +967,19 @@ pub enum ColdStartOutcome { #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub struct ColdStartReport { + /// The pool this report is for. pub pool: PoolKey, + /// The policy the cold-start ran under. pub policy: ColdStartPolicy, + /// The pool's resulting status. pub status: PoolStatus, + /// Every slot the run requested be verified. pub verified_slots: Vec<(Address, U256)>, + /// The slots whose value changed and were injected. pub changed_slots: Vec, + /// The diff applied to the cache during the run. pub applied: StateDiff, + /// Deferred work produced by a `Lazy` run (empty otherwise). pub deferred: Vec, /// Verified-code-seed results, when seeding ran for this cold-start (an /// account-fields fetcher was present, seeding was enabled, and the adapter @@ -809,6 +988,7 @@ pub struct ColdStartReport { } impl ColdStartReport { + /// An empty report for `pool` under `policy` (status [`PoolStatus::Pending`]). pub fn new(pool: PoolKey, policy: ColdStartPolicy) -> Self { Self { pool, @@ -827,12 +1007,18 @@ impl ColdStartReport { #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub enum DeferredWork { + /// Warm (verify) these `(address, slot)` pairs when the consumer is ready. VerifySlots(Vec<(Address, U256)>), + /// A repair action deferred for later execution. Repair(RepairAction), + /// Re-cold-start a pool under `policy`, deferred to the caller. ColdStart { + /// The pool to cold-start. pool: PoolKey, + /// The policy to cold-start it under. policy: ColdStartPolicy, }, + /// Protocol-specific deferred work, described by a string tag. Custom(String), } @@ -847,7 +1033,9 @@ pub enum DeferredWork { #[non_exhaustive] #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct DeferredOutcome { + /// Slot changes produced by warming the handled `VerifySlots` work. pub verified: Vec, + /// Deferred work this driver did not execute (pushed on verbatim). pub unhandled: Vec, } @@ -862,9 +1050,13 @@ impl DeferredOutcome { #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub enum UnsupportedReason { + /// No adapter is registered / implemented for this protocol. Protocol(ProtocolId), + /// Required metadata (e.g. a storage layout) is missing. MissingMetadata(&'static str), + /// The event uses adapter-defined routing that this path cannot resolve. AdapterDefinedRouting, + /// A protocol-specific unsupported reason. Custom(String), } diff --git a/src/adapters/uniswap_v2.rs b/src/adapters/uniswap_v2.rs index f6aca9f..bba9121 100644 --- a/src/adapters/uniswap_v2.rs +++ b/src/adapters/uniswap_v2.rs @@ -19,6 +19,7 @@ sol! { event Sync(uint112 reserve0, uint112 reserve1); } +/// Adapter for Uniswap V2 constant-product pairs. #[derive(Clone, Debug, Default)] pub struct UniswapV2Adapter { _private: (), diff --git a/src/lib.rs b/src/lib.rs index 5bcb3ed..dbf6964 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,5 +23,10 @@ //! cold-starts a pool, subscribes to its events over a WebSocket endpoint, //! applies them reactively, and simulates a swap against the live-synced state. +// The public API is broad and stability-sensitive; require docs on every public +// item so the surface stays fully documented as it grows (CI's `-D warnings` +// promotes this to an error). +#![warn(missing_docs)] + // Always compiled — the adapter layer has no heavy deps. pub mod adapters; From 7ff2e6fb37c25b8968f060ef3d9f41ca6ec68f6c Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 14:09:20 +0100 Subject: [PATCH 5/9] 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 9090c4178269a473ba614f3ad18afebfcd498d4f Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 14:05:19 +0100 Subject: [PATCH 6/9] feat(uniswap-v3): event-source Mint/Burn liquidity (no RPC where warm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uniswap V3 `Mint`/`Burn` previously always emitted a tick-range resync (an RPC round trip). They now event-source the affected state directly wherever it is already warm, matching the exact-write posture of the V2/Solidly `Sync` path, and resync ONLY for ticks outside the warmed window. The event carries the exact liquidity delta and the boundary ticks; the current tick comes from cached `slot0`. For each warm boundary tick the adapter read-modify-writes the packed `Tick.Info` word 0 — `liquidityGross` (low 128) and `liquidityNet` (high 128, opposite signs for the lower vs upper tick) — and toggles the `tickBitmap` bit on an init/clear (Uniswap `flipTick` is an XOR); the in-range global `liquidity` slot is adjusted by `±amount`. Those are exactly the slots a `QuoterV2` swap reads; `feeGrowthOutside`/`positions` are accounting-only (they do not affect `amountOut`) and are intentionally not maintained. A cold boundary tick (word 0 not cached) cannot be read-modify-written, so its info + bitmap slots fall back to a `VerifySlots` resync — the hybrid write-where-warm / resync-cold policy. No-layout pools still degrade to a conservative whole-storage invalidation. Correctness rests on the reactive runtime applying each input's updates before the next input's decode (verified in `ingest_batch_direct`), so read-modify-write sees prior events in the batch. Bitmap flips for both ticks in a shared word are accumulated into one combined write (two full-slot writes from the same pre-event view would not compose). Tests: 9 inline unit tests (packing round-trip incl. negative net, all four mint/burn sign combos, init/clear detection, overflow rejection, bit-position vs Uniswap `position`); reactive integration tests for warm direct writes, out-of-range liquidity, burn-to-zero bitmap clear (shared word), and cold-tick resync fallback; Pancake/Slipstream layout coverage retained. `RepairAction:: V3TickRange` is retained as a reserved variant. Full gate green (tests all/default/no-default, clippy -D warnings ×2, doc, fmt, missing_docs=0). Follow-up: an env-gated RPC parity test (apply a real Mint/Burn, compare event-sourced slots to eth_getStorageAt ground truth at the post-event block) — deferred pending an archive endpoint to author it against. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 18 +- README.md | 2 +- docs/protocol-support-matrix.md | 2 +- src/adapters/uniswap_v3.rs | 352 ++++++++++++++++++++++++-- tests/adapter_reactive.rs | 426 +++++++++++++++++++++++++++----- 5 files changed, 710 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82328ff..42f70d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,8 +76,11 @@ register → cold-start → subscribe → react → simulate. exact masked reserve write (event-sourced, no refetch). - **Uniswap V3 family** (V3, PancakeSwap V3, Slipstream) — `QuoterV2` quotes; slot0 + liquidity + a bounded, fixed-radius **multi-word tick-window - warm-up** at cold-start; `Swap` → slot0/liquidity, `Mint`/`Burn` → tick-range - resync. + warm-up** at cold-start; `Swap` → slot0/liquidity. `Mint`/`Burn` are + **event-sourced**: the exact `liquidityGross`/`liquidityNet` (packed word 0), + `tickBitmap` bit, and in-range global `liquidity` are written directly from the + event for warm (in-window) ticks with no RPC, and only genuinely-cold ticks + fall back to a targeted resync. - **Balancer V2** — `Vault.queryBatchSwap` quotes; discover→verify cold-start (`getPoolTokens` read-set); `Swap` → balance-slot resync. - **Solidly V2** (Aerodrome / Velodrome) — pool `getAmountOut` quotes; @@ -91,10 +94,13 @@ register → cold-start → subscribe → react → simulate. quote entrypoint inside a local revm against the warmed cache, then decodes the result. There is **no reimplemented AMM math**. -**Reactive synchronization** — fully offline (no RPC in the hot path). Pools -whose events carry absolute state are event-sourced with exact writes (Uniswap -V2 / Solidly `Sync`); pools whose events carry deltas re-verify just the -affected slots (Uniswap V3 tick ranges, Balancer / Curve `VerifySlots`). +**Reactive synchronization** — fully offline (no RPC in the hot path) for the +common case. Pools whose events carry absolute state are event-sourced with exact +writes (Uniswap V2 / Solidly `Sync`); Uniswap V3 `Mint`/`Burn` are event-sourced +too, applying the exact liquidity delta to the warmed tick/bitmap/liquidity slots +and resyncing only ticks outside the warmed window; Balancer / Curve events carry +deltas over a non-predictable layout and re-verify the discovered slots +(`VerifySlots`). **Testing & CI** diff --git a/README.md b/README.md index 15fc29b..ea69479 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Each protocol is a single [`AmmAdapter`] implementation; the | Protocol | Feature | Quote entrypoint | Cold-start | Reactive | | --- | --- | --- | --- | --- | | Uniswap V2 | `uniswap-v2` | `Router02.getAmountsOut` | named slots | `Sync` → exact masked write | -| Uniswap V3 family (V3, PancakeSwap V3, Slipstream) | `uniswap-v3` (`pancake-v3`, `slipstream`) | `QuoterV2.quoteExactInputSingle` | slot0 + liquidity + multi-word tick scan (per-pool radius), or the one-shot full-range program sync (`v3_sync`) | `Swap` → slot0/liquidity; `Mint`/`Burn` → tick-range resync | +| Uniswap V3 family (V3, PancakeSwap V3, Slipstream) | `uniswap-v3` (`pancake-v3`, `slipstream`) | `QuoterV2.quoteExactInputSingle` | slot0 + liquidity + multi-word tick scan (per-pool radius), or the one-shot full-range program sync (`v3_sync`) | `Swap` → slot0/liquidity; `Mint`/`Burn` → exact tick + global-liquidity writes where warm, resync only cold ticks | | Balancer V2 | `balancer-v2` | `Vault.queryBatchSwap` | discover → verify (`getPoolTokens`) | `Swap` → balance-slot resync | | Solidly V2 (Aerodrome / Velodrome) | `solidly-v2` | pool `getAmountOut` | named slots (config layout) | `Sync` → two exact slot writes | | **Curve** (StableSwap, StableSwap-NG, CryptoSwap v2, Tricrypto-NG) | `curve` | pool `get_dy` | discover → verify (`get_dy` read-set) | `TokenExchange` + liquidity events → slot resync | diff --git a/docs/protocol-support-matrix.md b/docs/protocol-support-matrix.md index 54d000d..4f9b6ce 100644 --- a/docs/protocol-support-matrix.md +++ b/docs/protocol-support-matrix.md @@ -10,7 +10,7 @@ backend or extra setup is still needed. This complements the summary table in th | Protocol (feature) | Cold-start | Offline quote after cold-start | Reactive event → state | Factory discovery | Known limitations | | --- | --- | --- | --- | --- | --- | | **Uniswap V2** (`uniswap-v2`) | named slots (token0/1, reserves) | ✅ fully offline | `Sync` → **exact** masked write, no RPC | ✅ `getPair[t0][t1]` | — | -| **Uniswap V3** (`uniswap-v3`) | slot0 + liquidity + a bounded multi-word tick window (all four `Tick.Info` words), or the one-shot full-range program | ✅ offline within the warmed tick window; a swap crossing beyond it lazily fetches (or use the one-shot full sync for zero-lazy) | `Swap` → **exact** slot0/liquidity; `Mint`/`Burn` → bounded tick-range **resync** | ✅ fee-keyed `getPool[t0][t1][fee]` | — | +| **Uniswap V3** (`uniswap-v3`) | slot0 + liquidity + a bounded multi-word tick window (all four `Tick.Info` words), or the one-shot full-range program | ✅ offline within the warmed tick window; a swap crossing beyond it lazily fetches (or use the one-shot full sync for zero-lazy) | `Swap` → **exact** slot0/liquidity; `Mint`/`Burn` → **exact** direct writes to warm ticks (packed `liquidityGross`/`liquidityNet`, bitmap flip) + in-range global liquidity, **resync** only for cold (out-of-window) ticks | ✅ fee-keyed `getPool[t0][t1][fee]` | — | | **PancakeSwap V3** (`pancake-v3`) | as Uniswap V3 (Pancake slot layout) | ✅ as Uniswap V3 | as Uniswap V3 (Pancake `Swap` topic) | ✅ fee-keyed | one-shot full sync uses the layout-only `core` spec (Pancake's fee-growth/observation slots are unverified) | | **Slipstream / Aerodrome CL** (`slipstream`) | as Uniswap V3 (Slipstream slot layout) | ⚠️ **discovery + cold-start only** | as Uniswap V3 | ✅ tickSpacing-keyed `getPool[t0][t1][spacing]` | discovered `fee` is left unset (its quoter takes a different ABI); `simulate_swap` returns `MissingMetadata` unless the caller supplies a compatible quoter + fee | | **Balancer V2** (`balancer-v2`) | discover→verify (`getPoolTokens` read-set) | ✅ (the vault's code is lazily fetched on the first quote) | `Swap` → balance-slot **resync** | ❌ not shipped (no on-chain token→pool index; needs an async log scan) | register pools explicitly | diff --git a/src/adapters/uniswap_v3.rs b/src/adapters/uniswap_v3.rs index b05d755..15356bd 100644 --- a/src/adapters/uniswap_v3.rs +++ b/src/adapters/uniswap_v3.rs @@ -163,7 +163,7 @@ impl AmmAdapter for ConcentratedLiquidityAdapter { &self, pool: &PoolRegistration, log: &Log, - _view: &dyn StateView, + view: &dyn StateView, ) -> AdapterEventResult { let Some(topic0) = log.topics().first().copied() else { return AdapterEventResult::ignored(); @@ -174,9 +174,9 @@ impl AmmAdapter for ConcentratedLiquidityAdapter { } else if topic0 == PancakeV3Swap::SIGNATURE_HASH { self.decode_swap(pool, log, topic0, SwapAbi::Pancake) } else if topic0 == Mint::SIGNATURE_HASH { - self.decode_tick_range_repair(pool, log, true) + self.decode_liquidity_event(pool, log, view, true) } else if topic0 == Burn::SIGNATURE_HASH { - self.decode_tick_range_repair(pool, log, false) + self.decode_liquidity_event(pool, log, view, false) } else { AdapterEventResult::ignored() } @@ -370,10 +370,33 @@ impl ConcentratedLiquidityAdapter { }) } - fn decode_tick_range_repair( + /// Decode a Uniswap V3 `Mint`/`Burn` and **event-source** the affected state + /// directly wherever it is already warm — no RPC — falling back to a targeted + /// resync only for boundary ticks whose base value is cold (outside the warmed + /// window). + /// + /// The event carries the exact liquidity delta (`amount`) and the boundary + /// ticks; the current tick comes from cached `slot0`. For each **warm** + /// boundary tick this read-modify-writes the packed `Tick.Info` word 0 + /// (`liquidityGross` in the low 128 bits, `liquidityNet` in the high 128 — + /// moving in *opposite* directions for the lower vs. upper tick) and toggles + /// the `tickBitmap` bit when the tick initializes or clears (the contract's + /// `flipTick` is exactly an XOR of that bit). The global `liquidity` slot is + /// adjusted by `±amount` when the position straddles the current tick. Those + /// are precisely the slots a `QuoterV2` swap reads; `feeGrowthOutside` and the + /// `positions` mapping are accounting-only (they do not affect `amountOut`) and + /// are intentionally not maintained here. + /// + /// A **cold** boundary tick (its word 0 not cached) cannot be + /// read-modify-written, so its info + bitmap slots are emitted as a + /// [`RepairAction::VerifySlots`] resync instead — the hybrid write-where-warm / + /// resync-cold policy. A pool with no resolvable layout falls back to a + /// conservative whole-storage invalidation. + fn decode_liquidity_event( &self, pool: &PoolRegistration, log: &Log, + view: &dyn StateView, is_mint: bool, ) -> AdapterEventResult { let decode_ok = if is_mint { @@ -397,6 +420,8 @@ impl ConcentratedLiquidityAdapter { "missing V3 tickUpper topic", )); }; + let tick_lower = topic_to_i32(tick_lower_topic); + let tick_upper = topic_to_i32(tick_upper_topic); let topic0 = if is_mint { Mint::SIGNATURE_HASH @@ -408,22 +433,157 @@ impl ConcentratedLiquidityAdapter { } else { AdapterEventKind::LiquidityRemoved }; - let tick_lower = topic_to_i32(tick_lower_topic); - let tick_upper = topic_to_i32(tick_upper_topic); - AdapterEventResult::event(AdapterEvent { - pool: pool.key.clone(), - emitter: log.address, - topic0, - kind, - updates: Vec::new(), - quality: UpdateQuality::RequiresRepair, - repair: RepairAction::V3TickRange { - pool: pool.key.clone(), - tick_lower, - tick_upper, - }, - }) + let Some(address) = pool.key.address() else { + return AdapterEventResult::error(AdapterEventError::MalformedLog( + "V3 pool key is not address-keyed", + )); + }; + + // The `amount` (uint128 liquidity L) is the first NON-indexed data word: + // index 1 for Mint (a non-indexed `sender` precedes it) and index 0 for + // Burn (no leading non-indexed field). + let amount_word_index = if is_mint { 1 } else { 0 }; + let Some(amount_word) = data_word(log, amount_word_index) else { + return AdapterEventResult::error(AdapterEventError::MalformedLog( + "missing V3 liquidity amount", + )); + }; + let amount = u128_low(amount_word); + + // Without a resolvable layout the protocol slots cannot be named safely, so + // conservatively invalidate all of the pool's storage (prior behavior). + let Some(layout) = layout_for(pool) else { + return AdapterEventResult::event( + AdapterEvent::new( + pool.key.clone(), + log.address, + topic0, + kind, + UpdateQuality::RequiresRepair, + ) + .with_repair(RepairAction::PurgeStorage(address)), + ); + }; + + let mut updates: Vec = Vec::new(); + let mut resync: Vec<(Address, U256)> = Vec::new(); + + // Current tick from cached slot0 drives the in-range check for the global + // liquidity. slot0 is a mandatory cold-start slot; the boundary-tick writes + // below are independent of it. + let current_tick = view + .storage(address, layout.slot0_slot) + .map(|slot0| int24_from_word(slot0 >> SLOT0_TICK_SHIFT)); + + match current_tick { + // In range: apply ±amount to the warm global liquidity, or resync it. + Some(tick) if tick_lower <= tick && tick < tick_upper => { + match view.storage(address, layout.liquidity_slot) { + Some(old) => { + let new = if is_mint { + old.saturating_add(U256::from(amount)) + } else { + old.saturating_sub(U256::from(amount)) + }; + updates.push(StateUpdate::slot(address, layout.liquidity_slot, new)); + } + None => resync.push((address, layout.liquidity_slot)), + } + } + // Out of range: the position does not straddle the current tick, so the + // global liquidity is unaffected. + Some(_) => {} + // slot0 cold (a degraded pool): the in-range decision cannot be made, so + // conservatively resync the global liquidity slot to its on-chain truth + // rather than silently dropping a possible delta (self-healing, and it + // correctly forces RequiresRepair). + None => resync.push((address, layout.liquidity_slot)), + } + + // Bitmap-bit flips are accumulated per bitmap word as an XOR mask, then + // emitted as ONE combined write per word below. Both boundary ticks can + // land in the same word; two separate full-slot writes — each computed + // from the same pre-event `view` — would not compose (the second would + // clobber the first), so they must be merged before writing. + let mut bitmap_toggles: Vec<(U256, U256)> = Vec::new(); + + // Each boundary tick: read-modify-write the packed `Tick.Info` word 0 (and + // record a bitmap-bit flip on an init/clear) when warm; resync when cold. + for (tick, is_lower) in [(tick_lower, true), (tick_upper, false)] { + let keys = v3_tick_info_storage_keys_with_base(tick, layout.ticks_base_slot); + let word_pos = v3_word_position(tick, layout.tick_spacing); + let bitmap_key = + v3_tick_bitmap_storage_key_with_base(word_pos, layout.tick_bitmap_base_slot); + + let cold_fallback = |resync: &mut Vec<(Address, U256)>| { + resync.extend(keys.iter().map(|slot| (address, *slot))); + resync.push((address, bitmap_key)); + }; + + let Some(old_word0) = view.storage(address, keys[0]) else { + // Cold tick: cannot read-modify-write; resync its info + bitmap slots. + cold_fallback(&mut resync); + continue; + }; + + let Some((new_word0, was_init, now_init)) = + apply_liquidity_delta(old_word0, amount, is_mint, is_lower) + else { + // Arithmetic out of range (should not happen for valid chain data): + // resync this tick rather than write a wrong value. + cold_fallback(&mut resync); + continue; + }; + + updates.push(StateUpdate::slot(address, keys[0], new_word0)); + + // The bitmap bit flips exactly when the tick's initialized state + // changes (Uniswap `flipTick` XORs the bit). + if was_init != now_init { + if view.storage(address, bitmap_key).is_some() { + let mask = U256::from(1u8) << v3_bit_position(tick, layout.tick_spacing); + match bitmap_toggles + .iter_mut() + .find(|(key, _)| *key == bitmap_key) + { + Some((_, acc)) => *acc ^= mask, + None => bitmap_toggles.push((bitmap_key, mask)), + } + } else { + // Cold bitmap word: cannot toggle without the base; resync it. + resync.push((address, bitmap_key)); + } + } + } + + // Emit one combined write per touched bitmap word (base XOR accumulated + // mask), so both ticks' flips in a shared word compose correctly. + for (bitmap_key, mask) in bitmap_toggles { + match view.storage(address, bitmap_key) { + Some(base) => updates.push(StateUpdate::slot(address, bitmap_key, base ^ mask)), + None => resync.push((address, bitmap_key)), + } + } + + // Dedup the resync set (both boundary ticks can share a bitmap word). + resync.sort_unstable(); + resync.dedup(); + + let (quality, repair) = if resync.is_empty() { + (UpdateQuality::Exact, RepairAction::None) + } else { + ( + UpdateQuality::RequiresRepair, + RepairAction::VerifySlots(resync), + ) + }; + + AdapterEventResult::event( + AdapterEvent::new(pool.key.clone(), log.address, topic0, kind, quality) + .with_updates(updates) + .with_repair(repair), + ) } } @@ -714,3 +874,157 @@ fn topic_to_i32(topic: &B256) -> i32 { fn low_mask(bits: usize) -> U256 { (U256::from(1) << bits) - U256::from(1) } + +/// The low 128 bits of a 256-bit word (a `Tick.Info` word 0's `liquidityGross`). +fn u128_low(word: U256) -> u128 { + let limbs = word.as_limbs(); + (limbs[0] as u128) | ((limbs[1] as u128) << 64) +} + +/// The high 128 bits of a 256-bit word, as raw bits (word 0's `liquidityNet`, +/// two's-complement `int128`). +fn u128_high(word: U256) -> u128 { + let limbs = word.as_limbs(); + (limbs[2] as u128) | ((limbs[3] as u128) << 64) +} + +/// Pack `liquidityGross` (low 128) and `liquidityNet` (high 128, two's complement) +/// back into a `Tick.Info` word-0 value. +fn pack_gross_net(gross: u128, net: i128) -> U256 { + U256::from(gross) | (U256::from(net as u128) << 128) +} + +/// The `tickBitmap` bit index (0..256) for `tick`, matching the V3 +/// `TickBitmap.position` low byte (`compressed % 256`, floor-toward-negative). +/// `tick_spacing` must be positive (guaranteed by [`layout_for`]). +fn v3_bit_position(tick: i32, tick_spacing: i32) -> usize { + tick.div_euclid(tick_spacing).rem_euclid(256) as usize +} + +/// Apply a liquidity `amount` delta to a `Tick.Info` word 0, returning the new +/// packed word plus the tick's initialized state before/after. +/// +/// `liquidityGross` always moves by `+amount` (mint) / `-amount` (burn); +/// `liquidityNet` moves `+amount` for the lower tick and `-amount` for the upper +/// on a mint (negated on a burn) — captured by `add_to_net = is_mint == is_lower`. +/// Returns `None` on arithmetic overflow/underflow (invalid chain data) so the +/// caller can resync the tick instead of writing a wrong value. +fn apply_liquidity_delta( + word0: U256, + amount: u128, + is_mint: bool, + is_lower: bool, +) -> Option<(U256, bool, bool)> { + let old_gross = u128_low(word0); + let old_net = u128_high(word0) as i128; + let signed = i128::try_from(amount).ok()?; + + let new_gross = if is_mint { + old_gross.checked_add(amount)? + } else { + old_gross.checked_sub(amount)? + }; + let add_to_net = is_mint == is_lower; + let new_net = if add_to_net { + old_net.checked_add(signed)? + } else { + old_net.checked_sub(signed)? + }; + + let was_init = old_gross != 0; + let now_init = new_gross != 0; + Some((pack_gross_net(new_gross, new_net), was_init, now_init)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gross(word0: U256) -> u128 { + u128_low(word0) + } + fn net(word0: U256) -> i128 { + u128_high(word0) as i128 + } + + #[test] + fn pack_unpack_round_trips_including_negative_net() { + for (g, n) in [ + (0u128, 0i128), + (5, 7), + (u128::MAX, -1), + (123, i128::MIN), + (1, i128::MAX), + ] { + let w = pack_gross_net(g, n); + assert_eq!(gross(w), g); + assert_eq!(net(w), n); + } + } + + #[test] + fn mint_lower_adds_gross_and_net() { + // gross += amount (low), net += amount (high, lower tick). + let (w, was, now) = apply_liquidity_delta(pack_gross_net(10, 3), 4, true, true).unwrap(); + assert_eq!(gross(w), 14); + assert_eq!(net(w), 7); + assert!(was && now); + } + + #[test] + fn mint_upper_adds_gross_subtracts_net() { + let (w, _, _) = apply_liquidity_delta(pack_gross_net(10, 3), 4, true, false).unwrap(); + assert_eq!(gross(w), 14); + assert_eq!(net(w), -1); + } + + #[test] + fn burn_lower_subtracts_both() { + let (w, _, _) = apply_liquidity_delta(pack_gross_net(10, 3), 4, false, true).unwrap(); + assert_eq!(gross(w), 6); + assert_eq!(net(w), -1); + } + + #[test] + fn burn_upper_subtracts_gross_adds_net() { + let (w, _, _) = apply_liquidity_delta(pack_gross_net(10, 3), 4, false, false).unwrap(); + assert_eq!(gross(w), 6); + assert_eq!(net(w), 7); + } + + #[test] + fn mint_onto_empty_tick_reports_initialization() { + // A tick with zero gross that gains liquidity flips uninitialized→initialized. + let (w, was, now) = apply_liquidity_delta(U256::ZERO, 5, true, true).unwrap(); + assert_eq!(gross(w), 5); + assert_eq!(net(w), 5); + assert!(!was && now); + } + + #[test] + fn burn_to_zero_reports_clear_and_zeroes_word() { + // Burning all of a tick's gross flips initialized→uninitialized; the lower + // tick's net returns to zero, so word 0 is fully zero. + let (w, was, now) = apply_liquidity_delta(pack_gross_net(5, 5), 5, false, true).unwrap(); + assert_eq!(w, U256::ZERO); + assert!(was && !now); + } + + #[test] + fn burn_more_than_gross_is_rejected() { + assert!(apply_liquidity_delta(pack_gross_net(3, 3), 4, false, true).is_none()); + } + + #[test] + fn bit_position_matches_uniswap_position_low_byte() { + // spacing 1: compressed == tick; bit = tick mod 256 (floor for negatives). + assert_eq!(v3_bit_position(0, 1), 0); + assert_eq!(v3_bit_position(255, 1), 255); + assert_eq!(v3_bit_position(256, 1), 0); + assert_eq!(v3_bit_position(-1, 1), 255); // word -1, top bit + assert_eq!(v3_bit_position(-256, 1), 0); + // spacing 60: compressed = tick/60. + assert_eq!(v3_bit_position(60, 60), 1); + assert_eq!(v3_bit_position(120, 60), 2); + } +} diff --git a/tests/adapter_reactive.rs b/tests/adapter_reactive.rs index 6c8f59e..ed99cb8 100644 --- a/tests/adapter_reactive.rs +++ b/tests/adapter_reactive.rs @@ -221,28 +221,24 @@ fn word_pos(tick: i32, tick_spacing: i32) -> i16 { tick.div_euclid(tick_spacing).div_euclid(256) as i16 } -/// Independent oracle for the slot set a V3 liquidity-event repair must resync: -/// all four boundary `Tick.Info` slots, the (deduped) containing bitmap words, -/// and the global liquidity slot. Returned sorted and deduped. -fn expected_tick_repair_slots( +/// Oracle for the slot set a liquidity event resyncs when nothing is warmed +/// (`slot0` and both boundary ticks cold): all four `Tick.Info` slots of each +/// tick, the (deduped) containing bitmap words, and the global `liquidity` slot +/// (with `slot0` cold the in-range decision is unknown, so `liquidity` is +/// conservatively resynced rather than dropped). Sorted and deduped. +fn expected_cold_resync_slots( layout: V3StorageLayout, tick_lower: i32, tick_upper: i32, ) -> Vec { let mut slots = Vec::new(); for tick in [tick_lower, tick_upper] { - let keys = v3_tick_info_storage_keys_with_base(tick, layout.ticks_base_slot); - slots.extend_from_slice(&keys); - } - let mut words = vec![ - word_pos(tick_lower, layout.tick_spacing), - word_pos(tick_upper, layout.tick_spacing), - ]; - words.sort_unstable(); - words.dedup(); - for word in words { + slots.extend_from_slice(&v3_tick_info_storage_keys_with_base( + tick, + layout.ticks_base_slot, + )); slots.push(v3_tick_bitmap_storage_key_with_base( - word, + word_pos(tick, layout.tick_spacing), layout.tick_bitmap_base_slot, )); } @@ -252,6 +248,12 @@ fn expected_tick_repair_slots( slots } +/// Pack a `Tick.Info` word 0: `liquidityGross` (low 128) + `liquidityNet` (high +/// 128, two's complement). +fn packed_tick_word0(gross: u128, net: i128) -> U256 { + U256::from(gross) | (U256::from(net as u128) << 128) +} + fn v3_mint_log(pool: Address, tick_lower: i32, tick_upper: i32, block_number: u64) -> RpcLog { let mut data = address_word(Address::repeat_byte(0x03)); data.extend(abi_words([ @@ -590,30 +592,15 @@ async fn v3_swap_applies_slot0_and_liquidity_updates_through_runtime() -> Result Ok(()) } +// A Mint whose boundary ticks are cold (nothing warmed) cannot be +// read-modify-written, so the adapter emits a targeted resync over exactly those +// ticks' info + bitmap slots (no direct writes, no whole-storage invalidation). #[tokio::test] -async fn v3_mint_emits_tick_range_repair_hook() -> Result<()> { +async fn v3_mint_cold_ticks_emit_resync() -> Result<()> { let pool = Address::repeat_byte(0x42); + let layout = V3StorageLayout::uniswap(60); let tick_lower = 60; let tick_upper = 120; - let mut data = address_word(Address::repeat_byte(0x03)); - data.extend(abi_words([ - U256::from(1_u64), - U256::from(2_u64), - U256::from(3_u64), - ])); - let log = rpc_log( - pool, - vec![ - v3_mint_topic(), - topic_address(Address::repeat_byte(0x04)), - topic_i24(tick_lower), - topic_i24(tick_upper), - ], - data, - 14, - 0, - 0, - ); let mut cache = setup_cache().await?; let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); @@ -621,7 +608,10 @@ async fn v3_mint_emits_tick_range_repair_hook() -> Result<()> { let report = runtime.ingest_batch( &mut cache, - batch(vec![(ReactiveInput::Log(log), included_context(14, 0))]), + batch(vec![( + ReactiveInput::Log(v3_mint_log(pool, tick_lower, tick_upper, 14)), + included_context(14, 0), + )]), )?; assert_eq!(report.applied.len(), 1); @@ -629,17 +619,146 @@ async fn v3_mint_emits_tick_range_repair_hook() -> Result<()> { report.applied[0].quality, StateEffectQuality::RequiresRepair ); + // Nothing warm to write; the whole effect is the resync. assert!(report.applied[0].state_updates.is_empty()); - let repair_signal = report.applied[0] - .hook_signals - .iter() - .find(|signal| { - signal.namespace.as_ref() == "evm-amm-state" - && signal.kind.as_ref() == "amm.repair.v3_tick_range" - }) - .expect("V3 liquidity changes should emit a tick-range repair hook"); - assert_eq!(tag_value(&repair_signal.labels, "tick_lower"), Some("60")); - assert_eq!(tag_value(&repair_signal.labels, "tick_upper"), Some("120")); + assert_eq!(report.applied[0].resyncs.len(), 1); + let [ResyncTarget::StorageSlots { address, slots }] = + report.applied[0].resyncs[0].targets.as_slice() + else { + panic!("expected a single storage-slots resync target"); + }; + assert_eq!(*address, pool); + let mut got = slots.clone(); + got.sort_unstable(); + got.dedup(); + assert_eq!( + got, + expected_cold_resync_slots(layout, tick_lower, tick_upper) + ); + // The observability event hook is still emitted. + assert!( + report.applied[0] + .hook_signals + .iter() + .any(|signal| signal.kind.as_ref() == "amm.event") + ); + Ok(()) +} + +// A Mint whose ticks and global liquidity are already warmed applies exact +// direct writes with NO resync: gross/net on each boundary tick's packed word 0, +// and the in-range global liquidity — the event-sourced (no-RPC) hot path. +#[tokio::test] +async fn v3_mint_warm_applies_direct_writes() -> Result<()> { + let pool = Address::repeat_byte(0x4a); + let layout = V3StorageLayout::uniswap(60); + let tick_lower = 60; + let tick_upper = 180; // current tick 120 is in [60, 180) + let amount = 7u128; // v3_mint_log bakes amount = 7 + + let lower_key = v3_tick_info_storage_keys_with_base(tick_lower, layout.ticks_base_slot)[0]; + let upper_key = v3_tick_info_storage_keys_with_base(tick_upper, layout.ticks_base_slot)[0]; + + let mut cache = setup_cache().await?; + // Warm slot0 (current tick 120, in range), global liquidity, and both already + // initialized boundary ticks. + cache.apply_updates(&[ + StateUpdate::slot( + pool, + layout.slot0_slot, + v3_slot0_word(U256::from(1u64), 120, U256::ZERO), + ), + StateUpdate::slot(pool, layout.liquidity_slot, U256::from(1_000u64)), + StateUpdate::slot(pool, lower_key, packed_tick_word0(100, 40)), + StateUpdate::slot(pool, upper_key, packed_tick_word0(100, -40)), + ]); + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(v3_registry(pool))))?; + + let report = runtime.ingest_batch( + &mut cache, + batch(vec![( + ReactiveInput::Log(v3_mint_log(pool, tick_lower, tick_upper, 24)), + included_context(24, 0), + )]), + )?; + + assert_eq!(report.applied.len(), 1); + assert_eq!( + report.applied[0].quality, + StateEffectQuality::ExactFromInput + ); + assert!( + report.applied[0].resyncs.is_empty(), + "fully-warm liquidity event must not resync" + ); + + // Global in-range liquidity += amount. + assert_eq!( + cache.cached_storage_value(pool, layout.liquidity_slot), + Some(U256::from(1_000u64 + amount as u64)) + ); + // Lower tick: gross += amount, net += amount. + assert_eq!( + cache.cached_storage_value(pool, lower_key), + Some(packed_tick_word0(100 + amount, 40 + amount as i128)) + ); + // Upper tick: gross += amount, net -= amount. + assert_eq!( + cache.cached_storage_value(pool, upper_key), + Some(packed_tick_word0(100 + amount, -40 - amount as i128)) + ); + Ok(()) +} + +// A Mint whose range does NOT straddle the current tick leaves global liquidity +// untouched, while still updating the boundary ticks' gross/net. +#[tokio::test] +async fn v3_mint_out_of_range_leaves_global_liquidity() -> Result<()> { + let pool = Address::repeat_byte(0x4b); + let layout = V3StorageLayout::uniswap(60); + let tick_lower = 60; + let tick_upper = 180; // current tick 600 is ABOVE the range + let amount = 7u128; + let lower_key = v3_tick_info_storage_keys_with_base(tick_lower, layout.ticks_base_slot)[0]; + + let mut cache = setup_cache().await?; + cache.apply_updates(&[ + StateUpdate::slot( + pool, + layout.slot0_slot, + v3_slot0_word(U256::from(1u64), 600, U256::ZERO), + ), + StateUpdate::slot(pool, layout.liquidity_slot, U256::from(1_000u64)), + StateUpdate::slot(pool, lower_key, packed_tick_word0(100, 40)), + StateUpdate::slot( + pool, + v3_tick_info_storage_keys_with_base(tick_upper, layout.ticks_base_slot)[0], + packed_tick_word0(100, -40), + ), + ]); + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(v3_registry(pool))))?; + + runtime.ingest_batch( + &mut cache, + batch(vec![( + ReactiveInput::Log(v3_mint_log(pool, tick_lower, tick_upper, 25)), + included_context(25, 0), + )]), + )?; + + // Out of range: global liquidity unchanged; boundary tick still updated. + assert_eq!( + cache.cached_storage_value(pool, layout.liquidity_slot), + Some(U256::from(1_000u64)) + ); + assert_eq!( + cache.cached_storage_value(pool, lower_key), + Some(packed_tick_word0(100 + amount, 40 + amount as i128)) + ); Ok(()) } @@ -750,6 +869,194 @@ async fn removed_log_rolls_back_previously_applied_update() -> Result<()> { Ok(()) } +// A Burn that removes a warm tick's entire gross liquidity clears its bitmap bit +// (Uniswap `flipTick` XOR) and zeroes its word 0 — all event-sourced, no resync. +#[tokio::test] +async fn v3_burn_to_zero_clears_bitmap_bit() -> Result<()> { + let pool = Address::repeat_byte(0x4c); + let layout = V3StorageLayout::uniswap(60); + let tick_lower = 60; // bit 1 + let tick_upper = 180; // bit 3 (same bitmap word 0) + let amount = 7u128; // v3_burn_log bakes amount = 7 + + let lower_key = v3_tick_info_storage_keys_with_base(tick_lower, layout.ticks_base_slot)[0]; + let upper_key = v3_tick_info_storage_keys_with_base(tick_upper, layout.ticks_base_slot)[0]; + let bitmap_key = v3_tick_bitmap_storage_key_with_base( + word_pos(tick_lower, layout.tick_spacing), + layout.tick_bitmap_base_slot, + ); + let bits_set = (U256::from(1u8) << 1) | (U256::from(1u8) << 3); + + let mut cache = setup_cache().await?; + cache.apply_updates(&[ + StateUpdate::slot( + pool, + layout.slot0_slot, + v3_slot0_word(U256::from(1u64), 120, U256::ZERO), + ), + StateUpdate::slot(pool, layout.liquidity_slot, U256::from(1_000u64)), + // Each boundary tick holds exactly `amount` gross, so a full burn zeroes it. + StateUpdate::slot(pool, lower_key, packed_tick_word0(amount, amount as i128)), + StateUpdate::slot( + pool, + upper_key, + packed_tick_word0(amount, -(amount as i128)), + ), + StateUpdate::slot(pool, bitmap_key, bits_set), + ]); + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(v3_registry(pool))))?; + + let report = runtime.ingest_batch( + &mut cache, + batch(vec![( + ReactiveInput::Log(v3_burn_log(pool, tick_lower, tick_upper, 26)), + included_context(26, 0), + )]), + )?; + + assert!(report.applied[0].resyncs.is_empty()); + // Both ticks fully burned -> word 0 zeroed, both bitmap bits cleared (XOR). + assert_eq!( + cache.cached_storage_value(pool, lower_key), + Some(U256::ZERO) + ); + assert_eq!( + cache.cached_storage_value(pool, upper_key), + Some(U256::ZERO) + ); + assert_eq!( + cache.cached_storage_value(pool, bitmap_key), + Some(U256::ZERO) + ); + // In-range liquidity decreased by amount. + assert_eq!( + cache.cached_storage_value(pool, layout.liquidity_slot), + Some(U256::from(1_000u64 - amount as u64)) + ); + Ok(()) +} + +// One warm boundary tick + one cold: the warm tick gets a direct write, the cold +// tick is resynced (its info + bitmap slots) — the mixed hybrid path. +#[tokio::test] +async fn v3_mint_mixed_warm_and_cold_ticks() -> Result<()> { + let pool = Address::repeat_byte(0x4d); + let layout = V3StorageLayout::uniswap(60); + let tick_lower = 60; // warm + let tick_upper = 15_360; // cold, in a different bitmap word + let amount = 7u128; + let lower_key = v3_tick_info_storage_keys_with_base(tick_lower, layout.ticks_base_slot)[0]; + + let mut cache = setup_cache().await?; + // Warm slot0 (current tick 120, in range), liquidity, and ONLY the lower tick. + cache.apply_updates(&[ + StateUpdate::slot( + pool, + layout.slot0_slot, + v3_slot0_word(U256::from(1u64), 120, U256::ZERO), + ), + StateUpdate::slot(pool, layout.liquidity_slot, U256::from(1_000u64)), + StateUpdate::slot(pool, lower_key, packed_tick_word0(100, 40)), + ]); + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(v3_registry(pool))))?; + + let report = runtime.ingest_batch( + &mut cache, + batch(vec![( + ReactiveInput::Log(v3_mint_log(pool, tick_lower, tick_upper, 27)), + included_context(27, 0), + )]), + )?; + + assert_eq!( + report.applied[0].quality, + StateEffectQuality::RequiresRepair + ); + // Warm lower tick + in-range liquidity: exact direct writes. + assert_eq!( + cache.cached_storage_value(pool, lower_key), + Some(packed_tick_word0(100 + amount, 40 + amount as i128)) + ); + assert_eq!( + cache.cached_storage_value(pool, layout.liquidity_slot), + Some(U256::from(1_000u64 + amount as u64)) + ); + // Cold upper tick: exactly its info + bitmap slots resynced (nothing else). + let [ResyncTarget::StorageSlots { slots, .. }] = + report.applied[0].resyncs[0].targets.as_slice() + else { + panic!("expected a storage-slots resync target"); + }; + let mut got = slots.clone(); + got.sort_unstable(); + got.dedup(); + let mut expected = + v3_tick_info_storage_keys_with_base(tick_upper, layout.ticks_base_slot).to_vec(); + expected.push(v3_tick_bitmap_storage_key_with_base( + word_pos(tick_upper, layout.tick_spacing), + layout.tick_bitmap_base_slot, + )); + expected.sort_unstable(); + expected.dedup(); + assert_eq!(got, expected); + Ok(()) +} + +// A degraded pool with cold slot0 but warm ticks: the boundary ticks still get +// direct writes, and the global liquidity is conservatively RESYNCED (not +// silently dropped) since the in-range decision can't be made. +#[tokio::test] +async fn v3_mint_cold_slot0_resyncs_global_liquidity() -> Result<()> { + let pool = Address::repeat_byte(0x4e); + let layout = V3StorageLayout::uniswap(60); + let tick_lower = 60; + let tick_upper = 180; + let amount = 7u128; + let lower_key = v3_tick_info_storage_keys_with_base(tick_lower, layout.ticks_base_slot)[0]; + let upper_key = v3_tick_info_storage_keys_with_base(tick_upper, layout.ticks_base_slot)[0]; + + let mut cache = setup_cache().await?; + // Warm both ticks (no flip: gross stays nonzero) but NOT slot0 or liquidity. + cache.apply_updates(&[ + StateUpdate::slot(pool, lower_key, packed_tick_word0(100, 40)), + StateUpdate::slot(pool, upper_key, packed_tick_word0(100, -40)), + ]); + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(v3_registry(pool))))?; + + let report = runtime.ingest_batch( + &mut cache, + batch(vec![( + ReactiveInput::Log(v3_mint_log(pool, tick_lower, tick_upper, 28)), + included_context(28, 0), + )]), + )?; + + // Warm ticks: direct writes. + assert_eq!( + cache.cached_storage_value(pool, lower_key), + Some(packed_tick_word0(100 + amount, 40 + amount as i128)) + ); + // The global liquidity delta is not dropped: it is resynced (only that slot; + // ticks are warm and don't flip, so they need no resync). + assert_eq!( + report.applied[0].quality, + StateEffectQuality::RequiresRepair + ); + let [ResyncTarget::StorageSlots { slots, .. }] = + report.applied[0].resyncs[0].targets.as_slice() + else { + panic!("expected a storage-slots resync target"); + }; + assert_eq!(slots.as_slice(), &[layout.liquidity_slot]); + Ok(()) +} + #[tokio::test] async fn v3_mint_emits_targeted_tick_resync() -> Result<()> { let pool = Address::repeat_byte(0x43); @@ -798,28 +1105,20 @@ async fn v3_mint_emits_targeted_tick_resync() -> Result<()> { got.dedup(); assert_eq!( got, - expected_tick_repair_slots(layout, tick_lower, tick_upper) - ); - - // Observability hook is preserved alongside the executable resync. - assert!( - report.applied[0] - .hook_signals - .iter() - .any(|signal| signal.kind.as_ref() == "amm.repair.v3_tick_range") + expected_cold_resync_slots(layout, tick_lower, tick_upper) ); Ok(()) } #[tokio::test] -async fn v3_mint_resync_repairs_tick_and_liquidity_slots() -> Result<()> { +async fn v3_mint_resync_repairs_cold_tick_slots() -> Result<()> { let pool = Address::repeat_byte(0x44); let layout = V3StorageLayout::uniswap(60); let tick_lower = 60; let tick_upper = 180; let block = 21; - let expected = expected_tick_repair_slots(layout, tick_lower, tick_upper); + let expected = expected_cold_resync_slots(layout, tick_lower, tick_upper); let mut fetched: HashMap<(Address, U256), U256> = HashMap::new(); for (i, slot) in expected.iter().enumerate() { fetched.insert((pool, *slot), U256::from(1_000 + i as u64)); @@ -896,10 +1195,11 @@ async fn v3_burn_same_word_dedupes_bitmap_slot() -> Result<()> { let mut got = slots.clone(); got.sort_unstable(); got.dedup(); - // 2 ticks x 4 info words + 1 shared bitmap word + liquidity = 10 slots. + // Cold everything, shared bitmap word: 2 ticks x 4 info words + 1 shared + // bitmap word + global liquidity (slot0 cold -> conservatively resynced) = 10. assert_eq!( got, - expected_tick_repair_slots(layout, tick_lower, tick_upper) + expected_cold_resync_slots(layout, tick_lower, tick_upper) ); assert_eq!(got.len(), 10); Ok(()) @@ -1684,14 +1984,14 @@ async fn pancake_v3_mint_repair_targets_pancake_layout_slots() -> Result<()> { got.dedup(); assert_eq!( got, - expected_tick_repair_slots(layout, tick_lower, tick_upper), + expected_cold_resync_slots(layout, tick_lower, tick_upper), "repair must target the Pancake layout slots" ); // The Pancake slots are genuinely distinct from the Uniswap layout's, // proving the family adapter lowered the repair against the Pancake layout. assert_ne!( got, - expected_tick_repair_slots(V3StorageLayout::uniswap(60), tick_lower, tick_upper) + expected_cold_resync_slots(V3StorageLayout::uniswap(60), tick_lower, tick_upper) ); Ok(()) } @@ -1738,12 +2038,12 @@ async fn slipstream_mint_repair_targets_slipstream_layout_slots() -> Result<()> got.dedup(); assert_eq!( got, - expected_tick_repair_slots(layout, tick_lower, tick_upper), + expected_cold_resync_slots(layout, tick_lower, tick_upper), "repair must target the Slipstream layout slots" ); assert_ne!( got, - expected_tick_repair_slots(V3StorageLayout::uniswap(60), tick_lower, tick_upper) + expected_cold_resync_slots(V3StorageLayout::uniswap(60), tick_lower, tick_upper) ); Ok(()) } From f95d0151523b44b242129d5a9fca7597594df52b Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 14:30:07 +0100 Subject: [PATCH 7/9] 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 1daeb8256135818a703651ec30980439c06bcd09 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 14:57:51 +0100 Subject: [PATCH 8/9] test(uniswap-v3): live RPC parity for Mint/Burn event-sourcing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests/v3_liquidity_rpc.rs (env-gated, #[ignore]): for a real add- and remove-liquidity transaction, fetch the exact per-tx storage diff via trace_replayTransaction(stateDiff), warm the pre-tx state, apply the event through the adapter, and assert the event-sourced writes reproduce the on-chain post-tx values for every slot the adapter maintains — each boundary tick's packed Tick.Info word 0 (liquidityGross/liquidityNet, incl. a negative net in two's complement) and the in-range global liquidity. Verified live against a mainnet archive+trace endpoint: a JIT add + remove of the same liquidity on the USDC/WETH 0.05% pool — BOTH the Mint and the Burn reproduce the on-chain per-tx storage exactly (tickLower/tickUpper word0 + global liquidity). This is the RPC parity check deferred in the feature commit; it now exists and passes. serde_json added as a dev-dependency (parses the trace stateDiff JSON). Full gate green: clippy -D warnings (all-features + no-default), offline tests, fmt, doc, missing_docs=0. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 + Cargo.toml | 10 ++ tests/v3_liquidity_rpc.rs | 248 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 tests/v3_liquidity_rpc.rs diff --git a/Cargo.lock b/Cargo.lock index 13f26ef..1cc1a7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1903,6 +1903,7 @@ dependencies = [ "futures", "reqwest", "revm", + "serde_json", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 865e02d..47aaedf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,9 @@ tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] } # `plotters` deps — keeps the build lean and, since the crate deliberately ships # without rayon, avoids reintroducing it even in the dev/bench build. criterion = { version = "0.5", default-features = false } +# The live V3-liquidity parity test parses `trace_replayTransaction` stateDiff as +# raw JSON (per-tx storage before/after ground truth). Dev-only. +serde_json = "1.0" # Runnable adapters-path demo: register -> cold-start -> WS event subscribe -> # reactive apply -> simulate_swap. Env-gated; no-ops if the RPC/WS URL is unset. @@ -194,6 +197,13 @@ required-features = ["uniswap-v3"] name = "v3_full_sync_rpc" required-features = ["uniswap-v3"] +# Live parity for event-sourced V3 Mint/Burn (env-gated, #[ignore]): for a real +# add- and remove-liquidity tx, apply the event and assert the adapter's writes +# reproduce the on-chain per-tx storage diff (trace_replayTransaction stateDiff). +[[test]] +name = "v3_liquidity_rpc" +required-features = ["uniswap-v3"] + [[test]] name = "reactive_ws_e2e" required-features = ["uniswap-v2"] diff --git a/tests/v3_liquidity_rpc.rs b/tests/v3_liquidity_rpc.rs new file mode 100644 index 0000000..b88dcff --- /dev/null +++ b/tests/v3_liquidity_rpc.rs @@ -0,0 +1,248 @@ +//! Live RPC parity for **event-sourced** Uniswap V3 `Mint`/`Burn` (env-gated, +//! `#[ignore]`). +//! +//! For a real add-liquidity (`Mint`) and remove-liquidity (`Burn`) transaction, +//! this fetches the transaction's *exact per-tx* storage diff via +//! `trace_replayTransaction(stateDiff)` — the on-chain ground truth, immune to +//! other transactions in the same block — warms the pre-tx (`from`) values into a +//! cache, applies the event through the adapter, and asserts the adapter's +//! event-sourced writes reproduce the on-chain post-tx (`to`) values for every +//! slot the adapter maintains (each boundary tick's packed `Tick.Info` word 0 and +//! the in-range global `liquidity`). +//! +//! The pinned pair is a just-in-time (JIT) add + remove of the same liquidity on +//! the same in-range, already-initialized ticks of the USDC/WETH 0.05% pool, so +//! it exercises `liquidityGross`/`liquidityNet` (including a negative net in +//! two's complement) and the in-range liquidity delta against real state. +//! +//! Run: `E2E_RPC_URL= cargo test --test v3_liquidity_rpc -- --ignored --nocapture` + +use std::collections::HashMap; +use std::sync::Arc; + +use alloy_eips::BlockId; +use alloy_primitives::{Address, B256, U256, address}; +use alloy_provider::{Provider, RootProvider, network::AnyNetwork}; +use alloy_rpc_types_eth::Filter; +use anyhow::Result; +use evm_amm_state::adapters::driver::AdapterDriver; +use evm_amm_state::adapters::storage::{ + V3StorageLayout, v3_tick_bitmap_storage_key_with_base, v3_tick_info_storage_keys_with_base, + v3_word_position, +}; +use evm_amm_state::adapters::{ + AdapterCache, AdapterRegistry, AmmAdapter, ConcentratedLiquidityAdapter, PoolKey, + PoolRegistration, ProtocolMetadata, StateUpdate, V3Metadata, +}; +use evm_fork_cache::cache::EvmCache; + +const POOL: Address = address!("88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640"); +const TICK_SPACING: i32 = 10; +const TICK_LOWER: i32 = 201_470; +const TICK_UPPER: i32 = 201_480; +const EVENT_BLOCK: u64 = 0x184c2a1; // block containing both the mint and the burn + +// A JIT add (Mint) and remove (Burn) of the same liquidity on [201470, 201480]. +const MINT_TX: &str = "0xdfd6176e2c22e1acad7d7ebfff14541c2c5ed0c31ec06b4812391c200bbac5a5"; +const BURN_TX: &str = "0x9d230517d95e119b6a4b5c300c1184455018074ab361df3368330358cdb88e4c"; + +fn mint_topic() -> B256 { + alloy_primitives::keccak256("Mint(address,address,int24,int24,uint128,uint256,uint256)") +} +fn burn_topic() -> B256 { + alloy_primitives::keccak256("Burn(address,int24,int24,uint128,uint256,uint256)") +} + +fn parse_u256(hex: &str) -> U256 { + U256::from_str_radix(hex.trim_start_matches("0x"), 16).unwrap_or(U256::ZERO) +} + +/// Fetch the per-tx storage diff for `POOL`: slot -> (from, to). +async fn pool_state_diff( + provider: &RootProvider, + tx: &str, +) -> Result> { + let value: serde_json::Value = provider + .raw_request( + "trace_replayTransaction".into(), + (tx.to_string(), vec!["stateDiff".to_string()]), + ) + .await?; + + let pool_lc = format!("{POOL:?}").to_lowercase(); + let mut diff = HashMap::new(); + let Some(accounts) = value["stateDiff"].as_object() else { + anyhow::bail!("trace_replayTransaction returned no stateDiff (trace unsupported?)"); + }; + for (addr, account) in accounts { + if addr.to_lowercase() != pool_lc { + continue; + } + if let Some(storage) = account["storage"].as_object() { + for (slot_hex, change) in storage { + let slot = parse_u256(slot_hex); + if let Some(star) = change.get("*") { + diff.insert( + slot, + ( + parse_u256(star["from"].as_str().unwrap_or("0x0")), + parse_u256(star["to"].as_str().unwrap_or("0x0")), + ), + ); + } else if let Some(plus) = change.get("+") { + diff.insert( + slot, + (U256::ZERO, parse_u256(plus.as_str().unwrap_or("0x0"))), + ); + } + } + } + } + Ok(diff) +} + +async fn run_parity(is_mint: bool) -> Result<()> { + let Ok(url) = std::env::var("E2E_RPC_URL") else { + eprintln!("E2E_RPC_URL unset — skipping V3 liquidity RPC parity test."); + return Ok(()); + }; + let (tx, topic0) = if is_mint { + (MINT_TX, mint_topic()) + } else { + (BURN_TX, burn_topic()) + }; + + let provider = Arc::new(RootProvider::::connect(&url).await?); + let diff = pool_state_diff(&provider, tx).await?; + + let layout = V3StorageLayout::uniswap(TICK_SPACING); + let lower_w0 = v3_tick_info_storage_keys_with_base(TICK_LOWER, layout.ticks_base_slot)[0]; + let upper_w0 = v3_tick_info_storage_keys_with_base(TICK_UPPER, layout.ticks_base_slot)[0]; + let bitmap = v3_tick_bitmap_storage_key_with_base( + v3_word_position(TICK_LOWER, TICK_SPACING), + layout.tick_bitmap_base_slot, + ); + + // The adapter maintains exactly these on a warm in-range liquidity event. + let asserted = [ + ("tickLower.word0", lower_w0), + ("tickUpper.word0", upper_w0), + ("global liquidity", layout.liquidity_slot), + ]; + // Every asserted slot must actually be one the tx changed (else the test is + // vacuous / mis-mapped). + for (name, slot) in asserted { + assert!( + diff.contains_key(&slot), + "{name} ({slot:#x}) not in the tx storage diff — mapping/pin is wrong" + ); + } + // These ticks are already initialized (other LPs hold them), so this event + // does not flip the bitmap — and the adapter must not touch it. + assert!( + !diff.contains_key(&bitmap), + "unexpected bitmap change: pinned event should not (de)initialize a tick" + ); + + // Warm the slots the decode reads. Written slots (the tick word0s and, for an + // in-range event, global liquidity) use the exact per-tx `from`. Read-only + // slots — `slot0` (a burn reads it to decide in-range but does not write it) + // and the unchanged `bitmap` — are absent from the stateDiff, so fall back to + // their pre-block chain value. + let mut cache = EvmCache::at_block(provider.clone(), BlockId::number(EVENT_BLOCK - 1)).await; + let pre = |slot: U256| diff.get(&slot).map(|(from, _)| *from); + let pre_block = BlockId::number(EVENT_BLOCK - 1); + let slot0_pre = match pre(layout.slot0_slot) { + Some(from) => from, + None => { + provider + .get_storage_at(POOL, layout.slot0_slot) + .block_id(pre_block) + .await? + } + }; + let bitmap_pre = provider + .get_storage_at(POOL, bitmap) + .block_id(pre_block) + .await?; + AdapterCache::apply_updates( + &mut cache, + &[ + StateUpdate::slot(POOL, layout.slot0_slot, slot0_pre), + StateUpdate::slot( + POOL, + layout.liquidity_slot, + pre(layout.liquidity_slot).unwrap(), + ), + StateUpdate::slot(POOL, lower_w0, pre(lower_w0).unwrap()), + StateUpdate::slot(POOL, upper_w0, pre(upper_w0).unwrap()), + StateUpdate::slot(POOL, bitmap, bitmap_pre), + ], + ); + + // Register the pool and apply the real event through the adapter. + let adapter = Arc::new(ConcentratedLiquidityAdapter::default()); + let mut registration = PoolRegistration::new(PoolKey::UniswapV3(POOL)) + .with_state_address(POOL) + .with_metadata(ProtocolMetadata::UniswapV3( + V3Metadata::default() + .with_storage_layout(layout) + .with_tick_spacing(TICK_SPACING), + )); + let sources = adapter.event_sources(®istration); + registration = registration.with_event_sources(sources); + + let mut registry = AdapterRegistry::new(); + registry.register_adapter(adapter).unwrap(); + registry.register_pool(registration).unwrap(); + let driver = AdapterDriver::new(registry); + + // Fetch the real event log from the chain (its exact topics + data) rather + // than reconstructing it, so the test is immune to hand-copied hex errors. + let tx_hash: B256 = tx.parse()?; + let filter = Filter::new() + .address(POOL) + .event_signature(topic0) + .from_block(EVENT_BLOCK) + .to_block(EVENT_BLOCK); + let log = provider + .get_logs(&filter) + .await? + .into_iter() + .find(|entry| entry.transaction_hash == Some(tx_hash)) + .expect("event log not found in block") + .inner; + + driver + .apply_log(&mut cache, &log)? + .expect("event must route and apply"); + + // The adapter's event-sourced writes must equal the on-chain post-tx values. + for (name, slot) in asserted { + let (_from, to) = diff[&slot]; + assert_eq!( + cache.cached_storage_value(POOL, slot), + Some(to), + "{name} ({slot:#x}): event-sourced value != on-chain post-tx value" + ); + eprintln!( + "{} {name}: matches on-chain {to:#x}", + if is_mint { "MINT" } else { "BURN" } + ); + } + // The bitmap was not flipped, so it is left exactly as warmed. + assert_eq!(cache.cached_storage_value(POOL, bitmap), Some(bitmap_pre)); + Ok(()) +} + +#[tokio::test] +#[ignore = "requires an archive+trace RPC via E2E_RPC_URL; run with --ignored"] +async fn mint_event_sourcing_matches_onchain_state_diff() -> Result<()> { + run_parity(true).await +} + +#[tokio::test] +#[ignore = "requires an archive+trace RPC via E2E_RPC_URL; run with --ignored"] +async fn burn_event_sourcing_matches_onchain_state_diff() -> Result<()> { + run_parity(false).await +} From 172da9388e72dd00ee447a093531684a6907ac01 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 17:40:12 +0100 Subject: [PATCH 9/9] 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.