From c6f1197688196d8df763a78ea2f8127eeea8634f Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 23:42:25 +0100 Subject: [PATCH 01/14] refactor: privatize sol!-generated ABI bindings before first publish Module-scope sol! blocks generate pub items, so ~30 internal ABI types (quote-call structs, event decls, factory events) rendered on docs.rs as public API and froze ABI plumbing into the semver surface. Wrap each block in a crate-internal mod: sim's quote ABI is pub(crate) (cross-module consumers), event bindings are module-private, matching the existing pancake_v3/solidly_events idiom. The RPC-parity and WS-soak tests that borrowed these bindings now declare their own local sol! ground-truth ABI. Co-Authored-By: Claude Fable 5 --- src/adapters/balancer_v2.rs | 33 ++++--- src/adapters/curve.rs | 96 ++++++++++--------- src/adapters/factory.rs | 43 +++++---- src/adapters/sim.rs | 169 ++++++++++++++++++--------------- src/adapters/solidly_v2.rs | 14 ++- src/adapters/uniswap_v2.rs | 10 +- src/adapters/uniswap_v3.rs | 14 ++- tests/adapter_swap_sim_rpc.rs | 54 ++++++++++- tests/reactive_curve_ws_e2e.rs | 11 ++- tests/reactive_ws_e2e.rs | 5 +- tests/v3_full_sync_rpc.rs | 20 +++- 11 files changed, 294 insertions(+), 175 deletions(-) diff --git a/src/adapters/balancer_v2.rs b/src/adapters/balancer_v2.rs index 5e6857d..2404ffb 100644 --- a/src/adapters/balancer_v2.rs +++ b/src/adapters/balancer_v2.rs @@ -13,15 +13,25 @@ use super::{ RepairAction, SlotChange, StateUpdate, StateView, UnsupportedReason, UpdateQuality, }; use alloy_primitives::{Address, B256, Bytes, Log, U256}; -use alloy_sol_types::{SolCall, SolEvent, sol}; - -sol! { - event Swap(bytes32 indexed poolId, address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut); - /// Emitted by the vault on a join or exit — it changes the pool's vault - /// balances, so it is subscribed and resynced (event-sourcing its `deltas` / - /// `protocolFeeAmounts` is a follow-up). `topic0 = 0xe5ce2490…`. - event PoolBalanceChanged(bytes32 indexed poolId, address indexed liquidityProvider, address[] tokens, int256[] deltas, uint256[] protocolFeeAmounts); +use alloy_sol_types::{SolCall, SolEvent}; + +/// `sol!`-generated vault ABI bindings (crate-internal, not public API): +/// the `Swap` / `PoolBalanceChanged` events and the `getPoolTokens` +/// cold-start discovery call. +mod abi { + alloy_sol_types::sol! { + event Swap(bytes32 indexed poolId, address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut); + /// Emitted by the vault on a join or exit — it changes the pool's vault + /// balances, so it is subscribed and resynced (event-sourcing its `deltas` / + /// `protocolFeeAmounts` is a follow-up). `topic0 = 0xe5ce2490…`. + event PoolBalanceChanged(bytes32 indexed poolId, address indexed liquidityProvider, address[] tokens, int256[] deltas, uint256[] protocolFeeAmounts); + + /// Balancer V2 vault `getPoolTokens` for cold-start discovery. + function getPoolTokens(bytes32 poolId) + returns (address[] tokens, uint256[] balances, uint256 lastChangeBlock); + } } +use abi::{PoolBalanceChanged, Swap, getPoolTokensCall}; /// Width of the vault `BalanceAllocation` `cash` field (bits): a packed balance is /// `[lastChangeBlock : top 32][managed : bits 112–223][cash : bits 0–111]`. @@ -297,13 +307,6 @@ fn decode_liquidity_change(pool: &PoolRegistration, log: &Log) -> AdapterEventRe ) } -sol! { - /// Local Balancer V2 vault `getPoolTokens` ABI for cold-start discovery, - /// kept beside the adapter so it compiles under the `balancer-v2` feature. - function getPoolTokens(bytes32 poolId) - returns (address[] tokens, uint256[] balances, uint256 lastChangeBlock); -} - /// Adapter for Balancer V2 (shared-vault) pools. #[derive(Clone, Debug, Default)] pub struct BalancerV2Adapter { diff --git a/src/adapters/curve.rs b/src/adapters/curve.rs index c58622e..5a9c558 100644 --- a/src/adapters/curve.rs +++ b/src/adapters/curve.rs @@ -45,56 +45,63 @@ use super::{ SlotChange, StateView, UnsupportedReason, UpdateQuality, }; use alloy_primitives::{Address, B256, Bytes, Log, U256, keccak256}; -use alloy_sol_types::{SolCall, SolEvent, sol}; +use alloy_sol_types::{SolCall, SolEvent}; -sol! { - // Classic Curve StableSwap plain-pool events. Only the signature hashes are - // used for topic routing; the liquidity-event payloads are not decoded (the - // reactive path resyncs the discovered slots rather than applying deltas). - event TokenExchange(address indexed buyer, int128 sold_id, uint256 tokens_sold, int128 bought_id, uint256 tokens_bought); - event AddLiquidity(address indexed provider, uint256[3] token_amounts, uint256[3] fees, uint256 invariant, uint256 token_supply); - event RemoveLiquidity(address indexed provider, uint256[3] token_amounts, uint256[3] fees, uint256 token_supply); - event RemoveLiquidityOne(address indexed provider, uint256 token_amount, uint256 coin_amount); - event RemoveLiquidityImbalance(address indexed provider, uint256[3] token_amounts, uint256[3] fees, uint256 invariant, uint256 token_supply); -} +/// `sol!`-generated event bindings for topic routing (crate-internal, not +/// public API). +mod abi { + use alloy_sol_types::sol; -sol! { - // CryptoSwap (Curve v2, e.g. tricrypto2) events. Namespaced under an interface - // so the generated types don't collide with the StableSwap ones above. All - // signatures verified on-chain against tricrypto2 (eth_getLogs topic0 histogram). - // Only `TokenExchange` is decode-validated before emitting a Swap; the - // liquidity events route on topic only (the reactive path resyncs discovered - // slots, not deltas). Arities are derived from n_coins at routing time; these - // N=3 decls are the `#[cfg(test)]` reference for the derived hashes. - // - // `RemoveLiquidityOne` here is the **3-arg** form (token_amount, coin_index, - // coin_amount) — emitted by BOTH CryptoSwap v2 AND StableSwap-NG (classic - // StableSwap uses the 2-arg form above), so the StableSwap routing reuses this - // hash. CryptoSwap v2 has no RemoveLiquidityImbalance. - interface CurveCryptoSwapEvents { - event TokenExchange(address indexed buyer, uint256 sold_id, uint256 tokens_sold, uint256 bought_id, uint256 tokens_bought); - event AddLiquidity(address indexed provider, uint256[3] token_amounts, uint256 fee, uint256 token_supply); - event RemoveLiquidity(address indexed provider, uint256[3] token_amounts, uint256 token_supply); - event RemoveLiquidityOne(address indexed provider, uint256 token_amount, uint256 coin_index, uint256 coin_amount); + sol! { + // Classic Curve StableSwap plain-pool events. Only the signature hashes are + // used for topic routing; the liquidity-event payloads are not decoded (the + // reactive path resyncs the discovered slots rather than applying deltas). + event TokenExchange(address indexed buyer, int128 sold_id, uint256 tokens_sold, int128 bought_id, uint256 tokens_bought); + event AddLiquidity(address indexed provider, uint256[3] token_amounts, uint256[3] fees, uint256 invariant, uint256 token_supply); + event RemoveLiquidity(address indexed provider, uint256[3] token_amounts, uint256[3] fees, uint256 token_supply); + event RemoveLiquidityOne(address indexed provider, uint256 token_amount, uint256 coin_amount); + event RemoveLiquidityImbalance(address indexed provider, uint256[3] token_amounts, uint256[3] fees, uint256 invariant, uint256 token_supply); + } + + sol! { + // CryptoSwap (Curve v2, e.g. tricrypto2) events. Namespaced under an interface + // so the generated types don't collide with the StableSwap ones above. All + // signatures verified on-chain against tricrypto2 (eth_getLogs topic0 histogram). + // Only `TokenExchange` is decode-validated before emitting a Swap; the + // liquidity events route on topic only (the reactive path resyncs discovered + // slots, not deltas). Arities are derived from n_coins at routing time; these + // N=3 decls are the `#[cfg(test)]` reference for the derived hashes. + // + // `RemoveLiquidityOne` here is the **3-arg** form (token_amount, coin_index, + // coin_amount) — emitted by BOTH CryptoSwap v2 AND StableSwap-NG (classic + // StableSwap uses the 2-arg form above), so the StableSwap routing reuses this + // hash. CryptoSwap v2 has no RemoveLiquidityImbalance. + interface CurveCryptoSwapEvents { + event TokenExchange(address indexed buyer, uint256 sold_id, uint256 tokens_sold, uint256 bought_id, uint256 tokens_bought); + event AddLiquidity(address indexed provider, uint256[3] token_amounts, uint256 fee, uint256 token_supply); + event RemoveLiquidity(address indexed provider, uint256[3] token_amounts, uint256 token_supply); + event RemoveLiquidityOne(address indexed provider, uint256 token_amount, uint256 coin_index, uint256 coin_amount); + } } -} -sol! { - // Tricrypto-NG (Curve's newest crypto pools) events — EXTENDED forms with - // extra `fee`/`packed_price_scale` fields, so their signature hashes differ - // from CryptoSwap v2's. All verified on-chain against tricryptoUSDC + USDT - // (eth_getLogs topic0 histogram). `RemoveLiquidity` is identical to v2 (so it - // reuses `CurveCryptoSwapEvents::RemoveLiquidity`). `ClaimAdminFee` is routed - // because `claim_admin_fees` can update D/price_scale (the crypto read-set). - // Arities are derived from n_coins at routing time; these N=3 decls are the - // `#[cfg(test)]` reference + the TokenExchange decode-validation type. - interface CurveTricryptoNgEvents { - event TokenExchange(address indexed buyer, uint256 sold_id, uint256 tokens_sold, uint256 bought_id, uint256 tokens_bought, uint256 fee, uint256 packed_price_scale); - event AddLiquidity(address indexed provider, uint256[3] token_amounts, uint256 fee, uint256 token_supply, uint256 packed_price_scale); - event RemoveLiquidityOne(address indexed provider, uint256 token_amount, uint256 coin_index, uint256 coin_amount, uint256 approx_fee, uint256 packed_price_scale); - event ClaimAdminFee(address indexed admin, uint256 tokens); + sol! { + // Tricrypto-NG (Curve's newest crypto pools) events — EXTENDED forms with + // extra `fee`/`packed_price_scale` fields, so their signature hashes differ + // from CryptoSwap v2's. All verified on-chain against tricryptoUSDC + USDT + // (eth_getLogs topic0 histogram). `RemoveLiquidity` is identical to v2 (so it + // reuses `CurveCryptoSwapEvents::RemoveLiquidity`). `ClaimAdminFee` is routed + // because `claim_admin_fees` can update D/price_scale (the crypto read-set). + // Arities are derived from n_coins at routing time; these N=3 decls are the + // `#[cfg(test)]` reference + the TokenExchange decode-validation type. + interface CurveTricryptoNgEvents { + event TokenExchange(address indexed buyer, uint256 sold_id, uint256 tokens_sold, uint256 bought_id, uint256 tokens_bought, uint256 fee, uint256 packed_price_scale); + event AddLiquidity(address indexed provider, uint256[3] token_amounts, uint256 fee, uint256 token_supply, uint256 packed_price_scale); + event RemoveLiquidityOne(address indexed provider, uint256 token_amount, uint256 coin_index, uint256 coin_amount, uint256 approx_fee, uint256 packed_price_scale); + event ClaimAdminFee(address indexed admin, uint256 tokens); + } } } +use abi::{CurveCryptoSwapEvents, CurveTricryptoNgEvents, RemoveLiquidityOne, TokenExchange}; /// The `dx` used by the cold-start discover call. /// @@ -816,6 +823,7 @@ impl AdapterColdStartPlanner for CurveColdStartPlanner { #[cfg(test)] mod tests { + use super::abi::{AddLiquidity, RemoveLiquidity, RemoveLiquidityImbalance}; use super::*; // The arity-3 topics derived from `n_coins` must equal the `sol!`-macro diff --git a/src/adapters/factory.rs b/src/adapters/factory.rs index 6200d7f..33ba18c 100644 --- a/src/adapters/factory.rs +++ b/src/adapters/factory.rs @@ -40,14 +40,7 @@ use std::collections::HashMap; use std::fmt; -#[cfg(any(feature = "uniswap-v2", feature = "uniswap-v3", feature = "solidly-v2"))] -use alloy_primitives::B256; -use alloy_primitives::{Address, Log, U256}; -#[cfg(any(feature = "uniswap-v2", feature = "uniswap-v3", feature = "solidly-v2"))] -use alloy_sol_types::SolEvent; #[cfg(any(feature = "uniswap-v2", feature = "uniswap-v3"))] -use alloy_sol_types::sol; - #[cfg(any(feature = "uniswap-v2", feature = "uniswap-v3", feature = "solidly-v2"))] use super::ProtocolMetadata; #[cfg(feature = "solidly-v2")] @@ -61,6 +54,11 @@ use super::{AdapterCache, AdapterRegistry, EventSource, PoolKey, PoolRegistratio use crate::adapters::storage::SolidlyStorageLayout; #[cfg(feature = "uniswap-v3")] use crate::adapters::storage::V3StorageLayout; +#[cfg(any(feature = "uniswap-v2", feature = "uniswap-v3", feature = "solidly-v2"))] +use alloy_primitives::B256; +use alloy_primitives::{Address, Log, U256}; +#[cfg(any(feature = "uniswap-v2", feature = "uniswap-v3", feature = "solidly-v2"))] +use alloy_sol_types::SolEvent; /// Factory-level derivation helpers. pub mod derive { @@ -350,21 +348,32 @@ const PANCAKE_V3_QUOTER_V2: Address = #[cfg(feature = "solidly-v2")] const SOLIDLY_GET_POOL_BASE_SLOT: U256 = U256::from_limbs([5, 0, 0, 0]); +/// Uniswap V2 `PairCreated` factory event (crate-internal), wrapped like +/// [`solidly_events`] so the binding stays out of the public API. #[cfg(feature = "uniswap-v2")] -sol! { - event PairCreated(address indexed token0, address indexed token1, address pair, uint256 allPairsLength); +mod v2_factory_events { + alloy_sol_types::sol! { + event PairCreated(address indexed token0, address indexed token1, address pair, uint256 allPairsLength); + } } +#[cfg(feature = "uniswap-v2")] +use v2_factory_events::PairCreated; +/// CL-family factory pool-creation events (crate-internal, not public API). #[cfg(feature = "uniswap-v3")] -sol! { - /// Uniswap/Pancake-style fee-keyed pool-creation event. - event PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool); - - /// Slipstream/Aerodrome CL tickSpacing-keyed pool-creation event. The - /// indexed key is `tickSpacing` (int24) rather than a fee; the pool address - /// and (unindexed) fee follow in the data. - event PoolCreatedTickSpacing(address indexed token0, address indexed token1, int24 indexed tickSpacing, address pool, uint24 fee); +mod cl_factory_events { + alloy_sol_types::sol! { + /// Uniswap/Pancake-style fee-keyed pool-creation event. + event PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool); + + /// Slipstream/Aerodrome CL tickSpacing-keyed pool-creation event. The + /// indexed key is `tickSpacing` (int24) rather than a fee; the pool address + /// and (unindexed) fee follow in the data. + event PoolCreatedTickSpacing(address indexed token0, address indexed token1, int24 indexed tickSpacing, address pool, uint24 fee); + } } +#[cfg(feature = "uniswap-v3")] +use cl_factory_events::{PoolCreated, PoolCreatedTickSpacing}; /// Solidly V2 (Aerodrome / Velodrome V2) pool-creation event, wrapped in its own /// module so the generated struct keeps the on-chain name `PoolCreated` (whose diff --git a/src/adapters/sim.rs b/src/adapters/sim.rs index 0041c5a..4bbb9cb 100644 --- a/src/adapters/sim.rs +++ b/src/adapters/sim.rs @@ -18,7 +18,6 @@ //! live backend, or installed as a fixture for offline tests. use alloy_primitives::{Address, Bytes, U256, address}; -use alloy_sol_types::sol; use super::{AdapterCache, CallOutcome}; @@ -193,92 +192,104 @@ pub fn quote_via_call_from( } } -sol! { - /// Uniswap V3 `QuoterV2.quoteExactInputSingle` (the struct-arg variant). - /// - /// `sqrtPriceLimitX96 = 0` means "no limit" (quote the full input). Returns - /// `amountOut` plus auxiliary fields we ignore. - struct QuoteExactInputSingleParams { - address tokenIn; - address tokenOut; - uint256 amountIn; - uint24 fee; - uint160 sqrtPriceLimitX96; - } +/// `sol!`-generated ABI bindings for the canonical quote entrypoints. +/// +/// Crate-internal plumbing: the per-protocol `simulate_swap` implementations +/// build calldata and decode outputs with these. Deliberately not public API +/// — custom adapters declare their own bindings (see +/// `examples/custom_adapter.rs`) rather than reusing these. +pub(crate) mod abi { + use alloy_sol_types::sol; - function quoteExactInputSingle(QuoteExactInputSingleParams params) - returns ( - uint256 amountOut, - uint160 sqrtPriceX96After, - uint32 initializedTicksCrossed, - uint256 gasEstimate - ); + sol! { + /// Uniswap V3 `QuoterV2.quoteExactInputSingle` (the struct-arg variant). + /// + /// `sqrtPriceLimitX96 = 0` means "no limit" (quote the full input). Returns + /// `amountOut` plus auxiliary fields we ignore. + struct QuoteExactInputSingleParams { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint24 fee; + uint160 sqrtPriceLimitX96; + } - /// Uniswap V2 `UniswapV2Router02.getAmountsOut(amountIn, path)`. - /// - /// Runs the on-chain `UniswapV2Library` math against the warmed pair - /// reserves and returns the amount at each hop; the last element is the - /// output for the final token in `path`. - function getAmountsOut(uint256 amountIn, address[] path) - returns (uint256[] amounts); + function quoteExactInputSingle(QuoteExactInputSingleParams params) + returns ( + uint256 amountOut, + uint160 sqrtPriceX96After, + uint32 initializedTicksCrossed, + uint256 gasEstimate + ); - /// Solidly V2 (Velodrome / Aerodrome) `Pool.getAmountOut(amountIn, tokenIn)`. - /// - /// Subtracts the fee via an external `IPoolFactory(factory).getFee()` - /// STATICCALL, then applies the stable (x³y+y³x) or volatile (xy=k) invariant - /// in-EVM and returns the `tokenOut` amount. Beyond the reserves it reads - /// `factory`/`stable`/`token0`/`decimals0`/`decimals1` from pool storage, so - /// the factory's bytecode + those slots must be reachable (not just reserves). - function getAmountOut(uint256 amountIn, address tokenIn) returns (uint256 amountOut); + /// Uniswap V2 `UniswapV2Router02.getAmountsOut(amountIn, path)`. + /// + /// Runs the on-chain `UniswapV2Library` math against the warmed pair + /// reserves and returns the amount at each hop; the last element is the + /// output for the final token in `path`. + function getAmountsOut(uint256 amountIn, address[] path) + returns (uint256[] amounts); - /// Curve StableSwap (plain pool) `get_dy(i, j, dx)`. - /// - /// `i`/`j` are the pool's coin indices (the `coins[]` ordering). Applies the - /// StableSwap invariant in-EVM against the warmed balances + amplification + - /// fee and returns the `j`-coin output for `dx` of coin `i`. This `int128` - /// binding serves the StableSwap / StableSwap-NG (int128-index) variants; the - /// `uint256` `CurveCryptoSwap::get_dy` below serves CryptoSwap / CryptoSwapNG. - /// The Curve adapter selects the correct binding per the pool's - /// [`CurveVariant`](super::CurveVariant). - function get_dy(int128 i, int128 j, uint256 dx) returns (uint256 dy); + /// Solidly V2 (Velodrome / Aerodrome) `Pool.getAmountOut(amountIn, tokenIn)`. + /// + /// Subtracts the fee via an external `IPoolFactory(factory).getFee()` + /// STATICCALL, then applies the stable (x³y+y³x) or volatile (xy=k) invariant + /// in-EVM and returns the `tokenOut` amount. Beyond the reserves it reads + /// `factory`/`stable`/`token0`/`decimals0`/`decimals1` from pool storage, so + /// the factory's bytecode + those slots must be reachable (not just reserves). + function getAmountOut(uint256 amountIn, address tokenIn) returns (uint256 amountOut); - /// Balancer V2 `Vault.queryBatchSwap(kind, swaps, assets, funds)`. - /// - /// `kind = 0` is `GIVEN_IN`. Returns the signed asset deltas (per `assets` - /// index): positive = owed to the vault (input), negative = paid out by the - /// vault (output). - function queryBatchSwap( - uint8 kind, - BatchSwapStep[] swaps, - address[] assets, - FundManagement funds - ) returns (int256[] assetDeltas); + /// Curve StableSwap (plain pool) `get_dy(i, j, dx)`. + /// + /// `i`/`j` are the pool's coin indices (the `coins[]` ordering). Applies the + /// StableSwap invariant in-EVM against the warmed balances + amplification + + /// fee and returns the `j`-coin output for `dx` of coin `i`. This `int128` + /// binding serves the StableSwap / StableSwap-NG (int128-index) variants; the + /// `uint256` `CurveCryptoSwap::get_dy` below serves CryptoSwap / CryptoSwapNG. + /// The Curve adapter selects the correct binding per the pool's + /// [`CurveVariant`](crate::adapters::CurveVariant). + function get_dy(int128 i, int128 j, uint256 dx) returns (uint256 dy); - struct BatchSwapStep { - bytes32 poolId; - uint256 assetInIndex; - uint256 assetOutIndex; - uint256 amount; - bytes userData; - } + /// Balancer V2 `Vault.queryBatchSwap(kind, swaps, assets, funds)`. + /// + /// `kind = 0` is `GIVEN_IN`. Returns the signed asset deltas (per `assets` + /// index): positive = owed to the vault (input), negative = paid out by the + /// vault (output). + function queryBatchSwap( + uint8 kind, + BatchSwapStep[] swaps, + address[] assets, + FundManagement funds + ) returns (int256[] assetDeltas); + + struct BatchSwapStep { + bytes32 poolId; + uint256 assetInIndex; + uint256 assetOutIndex; + uint256 amount; + bytes userData; + } - struct FundManagement { - address sender; - bool fromInternalBalance; - address recipient; - bool toInternalBalance; + struct FundManagement { + address sender; + bool fromInternalBalance; + address recipient; + bool toInternalBalance; + } } -} -sol! { - /// Curve CryptoSwap (Curve v2, e.g. tricrypto) `get_dy(i, j, dx)` — the - /// **uint256-index** variant (classic/NG StableSwap use the `int128` - /// `get_dy` above). Namespaced under an interface so its generated - /// `CurveCryptoSwap::get_dyCall` does not collide with the top-level - /// `int128` `get_dyCall`. Same semantics: chain code applies the CryptoSwap - /// invariant against the warmed state; this crate only builds calldata and - /// decodes the `uint256` output. - interface CurveCryptoSwap { - function get_dy(uint256 i, uint256 j, uint256 dx) returns (uint256 dy); + sol! { + /// Curve CryptoSwap (Curve v2, e.g. tricrypto) `get_dy(i, j, dx)` — the + /// **uint256-index** variant (classic/NG StableSwap use the `int128` + /// `get_dy` above). Namespaced under an interface so its generated + /// `CurveCryptoSwap::get_dyCall` does not collide with the top-level + /// `int128` `get_dyCall`. Same semantics: chain code applies the CryptoSwap + /// invariant against the warmed state; this crate only builds calldata and + /// decodes the `uint256` output. + interface CurveCryptoSwap { + function get_dy(uint256 i, uint256 j, uint256 dx) returns (uint256 dy); + } } } + +pub(crate) use abi::*; diff --git a/src/adapters/solidly_v2.rs b/src/adapters/solidly_v2.rs index 26291b2..70d6f88 100644 --- a/src/adapters/solidly_v2.rs +++ b/src/adapters/solidly_v2.rs @@ -12,13 +12,17 @@ use super::{ SolidlyV2Metadata, StateUpdate, StateView, UnsupportedReason, UpdateQuality, }; use alloy_primitives::{Address, Bytes, Log, U256}; -use alloy_sol_types::{SolCall, SolEvent, sol}; +use alloy_sol_types::{SolCall, SolEvent}; -sol! { - // Velodrome V2 / Aerodrome pools emit reserves as two separate uint256 values - // (unlike Uniswap V2's packed uint112,uint112). - event Sync(uint256 reserve0, uint256 reserve1); +/// `sol!`-generated pool event binding (crate-internal, not public API). +mod abi { + alloy_sol_types::sol! { + // Velodrome V2 / Aerodrome pools emit reserves as two separate uint256 values + // (unlike Uniswap V2's packed uint112,uint112). + event Sync(uint256 reserve0, uint256 reserve1); + } } +use abi::Sync; /// Adapter for Solidly V2 (Aerodrome / Velodrome V2) reserves pools. /// diff --git a/src/adapters/uniswap_v2.rs b/src/adapters/uniswap_v2.rs index bba9121..aa38b8a 100644 --- a/src/adapters/uniswap_v2.rs +++ b/src/adapters/uniswap_v2.rs @@ -13,11 +13,15 @@ use super::{ StateDiff, StateUpdate, StateView, UniswapV2Metadata, UnsupportedReason, UpdateQuality, }; use alloy_primitives::{Address, Bytes, Log, U256}; -use alloy_sol_types::{SolCall, SolEvent, sol}; +use alloy_sol_types::{SolCall, SolEvent}; -sol! { - event Sync(uint112 reserve0, uint112 reserve1); +/// `sol!`-generated pair event binding (crate-internal, not public API). +mod abi { + alloy_sol_types::sol! { + event Sync(uint112 reserve0, uint112 reserve1); + } } +use abi::Sync; /// Adapter for Uniswap V2 constant-product pairs. #[derive(Clone, Debug, Default)] diff --git a/src/adapters/uniswap_v3.rs b/src/adapters/uniswap_v3.rs index 15356bd..c8573d6 100644 --- a/src/adapters/uniswap_v3.rs +++ b/src/adapters/uniswap_v3.rs @@ -19,13 +19,17 @@ use crate::adapters::storage::{ v3_tick_info_storage_keys_with_base, v3_word_position, }; use alloy_primitives::{Address, B256, Bytes, Log, U256, aliases::U24}; -use alloy_sol_types::{SolCall, SolEvent, sol}; +use alloy_sol_types::{SolCall, SolEvent}; -sol! { - event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick); - event Mint(address sender, address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1); - event Burn(address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1); +/// `sol!`-generated pool event bindings (crate-internal, not public API). +mod abi { + alloy_sol_types::sol! { + event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick); + event Mint(address sender, address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1); + event Burn(address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1); + } } +use abi::{Burn, Mint, Swap}; /// PancakeSwap V3 `Swap` appends `protocolFeesToken0`/`protocolFeesToken1` /// (`uint128`) to the Uniswap V3 event, so its `topic0` differs (`0x19b47279…` diff --git a/tests/adapter_swap_sim_rpc.rs b/tests/adapter_swap_sim_rpc.rs index 514980f..fdacb8b 100644 --- a/tests/adapter_swap_sim_rpc.rs +++ b/tests/adapter_swap_sim_rpc.rs @@ -44,10 +44,6 @@ use alloy_rpc_types_eth::TransactionRequest; use alloy_sol_types::SolCall; use anyhow::{Context, Result, anyhow}; -use evm_amm_state::adapters::sim::{ - BatchSwapStep, CurveCryptoSwap, FundManagement, QuoteExactInputSingleParams, get_dyCall, - getAmountOutCall, getAmountsOutCall, queryBatchSwapCall, quoteExactInputSingleCall, -}; use evm_amm_state::adapters::storage::SolidlyStorageLayout; use evm_amm_state::adapters::{ AdapterRegistry, AmmAdapter, BalancerV2Adapter, BalancerV2Metadata, ColdStartPolicy, @@ -62,6 +58,56 @@ alloy_sol_types::sol! { /// storage-slot layout against the live pool's authoritative reserves. function reserve0() returns (uint256); function reserve1() returns (uint256); + + // Local quote-entrypoint ABI (the crate's own bindings are crate-internal): + // builds the ground-truth `eth_call`s the parity assertions compare against. + struct QuoteExactInputSingleParams { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint24 fee; + uint160 sqrtPriceLimitX96; + } + + function quoteExactInputSingle(QuoteExactInputSingleParams params) + returns ( + uint256 amountOut, + uint160 sqrtPriceX96After, + uint32 initializedTicksCrossed, + uint256 gasEstimate + ); + + function getAmountsOut(uint256 amountIn, address[] path) returns (uint256[] amounts); + + function getAmountOut(uint256 amountIn, address tokenIn) returns (uint256 amountOut); + + function get_dy(int128 i, int128 j, uint256 dx) returns (uint256 dy); + + function queryBatchSwap( + uint8 kind, + BatchSwapStep[] swaps, + address[] assets, + FundManagement funds + ) returns (int256[] assetDeltas); + + struct BatchSwapStep { + bytes32 poolId; + uint256 assetInIndex; + uint256 assetOutIndex; + uint256 amount; + bytes userData; + } + + struct FundManagement { + address sender; + bool fromInternalBalance; + address recipient; + bool toInternalBalance; + } + + interface CurveCryptoSwap { + function get_dy(uint256 i, uint256 j, uint256 dx) returns (uint256 dy); + } } const FORK_BLOCK: u64 = 20_000_000; diff --git a/tests/reactive_curve_ws_e2e.rs b/tests/reactive_curve_ws_e2e.rs index 48dc3fc..67441a7 100644 --- a/tests/reactive_curve_ws_e2e.rs +++ b/tests/reactive_curve_ws_e2e.rs @@ -32,7 +32,6 @@ use alloy_provider::{Provider, RootProvider}; use alloy_rpc_types_eth::{Filter, Log as RpcLog, TransactionRequest}; use alloy_sol_types::SolCall; use anyhow::{Context, Result, anyhow}; -use evm_amm_state::adapters::sim::{CurveCryptoSwap, get_dyCall}; use evm_amm_state::adapters::{ AdapterRegistry, AmmAdapter, AmmReactiveHandler, ColdStartPolicy, CurveAdapter, CurveMetadata, CurveVariant, PoolKey, PoolRegistration, ProtocolMetadata, SimConfig, @@ -44,6 +43,16 @@ use evm_fork_cache::reactive::{ }; use futures::StreamExt; +// Local `get_dy` quote ABI (the crate's own bindings are crate-internal): +// builds the ground-truth `eth_call`s the soak's parity checks compare against. +alloy_sol_types::sol! { + function get_dy(int128 i, int128 j, uint256 dx) returns (uint256 dy); + + interface CurveCryptoSwap { + function get_dy(uint256 i, uint256 j, uint256 dx) returns (uint256 dy); + } +} + // 3pool (StableSwap, DAI/USDC/USDT). const THREEPOOL: Address = address!("bEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7"); const DAI: Address = address!("6B175474E89094C44Da98b954EedeAC495271d0F"); diff --git a/tests/reactive_ws_e2e.rs b/tests/reactive_ws_e2e.rs index ccd0019..dd00b3c 100644 --- a/tests/reactive_ws_e2e.rs +++ b/tests/reactive_ws_e2e.rs @@ -38,7 +38,6 @@ use alloy_provider::{Provider, RootProvider}; use alloy_rpc_types_eth::{Filter, Log as RpcLog, TransactionRequest}; use alloy_sol_types::SolCall; use anyhow::{Context, Result, anyhow}; -use evm_amm_state::adapters::sim::getAmountsOutCall; use evm_amm_state::adapters::storage::V2_RESERVES_SLOT; use evm_amm_state::adapters::{ AdapterRegistry, AmmAdapter, AmmReactiveHandler, ColdStartPolicy, PoolKey, PoolRegistration, @@ -51,6 +50,10 @@ use evm_fork_cache::reactive::{ }; use futures::StreamExt; +// Local Router02 ABI: the crate's own quote-call bindings are crate-internal. +alloy_sol_types::sol! { + function getAmountsOut(uint256 amountIn, address[] path) returns (uint256[] amounts); +} const USDC: Address = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); const WETH: Address = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); const V2_ROUTER_02: Address = address!("7a250d5630B4cF539739dF2C5dAcb4c659F2488D"); diff --git a/tests/v3_full_sync_rpc.rs b/tests/v3_full_sync_rpc.rs index cd5a395..644c641 100644 --- a/tests/v3_full_sync_rpc.rs +++ b/tests/v3_full_sync_rpc.rs @@ -27,7 +27,6 @@ use alloy_rpc_types_eth::TransactionRequest; use alloy_sol_types::SolCall; use anyhow::{Context, Result, anyhow}; -use evm_amm_state::adapters::sim::{QuoteExactInputSingleParams, quoteExactInputSingleCall}; use evm_amm_state::adapters::storage::{ V3StorageLayout, v3_tick_bitmap_storage_key_with_base, v3_tick_info_storage_keys_with_base, v3_word_position, @@ -39,6 +38,25 @@ use evm_amm_state::adapters::{ }; use evm_fork_cache::cache::EvmCache; +// Local QuoterV2 ABI: the crate's own quote-call bindings are crate-internal. +alloy_sol_types::sol! { + struct QuoteExactInputSingleParams { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint24 fee; + uint160 sqrtPriceLimitX96; + } + + function quoteExactInputSingle(QuoteExactInputSingleParams params) + returns ( + uint256 amountOut, + uint160 sqrtPriceX96After, + uint32 initializedTicksCrossed, + uint256 gasEstimate + ); +} + const FORK_BLOCK: u64 = 20_000_000; const USDC: Address = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); From f146a7dbf66074bb4b5e4c9388c714ea877acc00 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 23:45:53 +0100 Subject: [PATCH 02/14] feat: re-export public deps + docs.rs feature badges - pub use alloy_primitives / evm_fork_cache at the root: both appear irreducibly in the public API (EvmCache, reactive runtime, Address/U256), so downstream can name them without hand-pinning matching versions. Documents the evm-fork-cache 0.x lockstep policy. - [package.metadata.docs.rs] rustdoc-args --cfg docsrs + feature(doc_cfg) (auto-cfg, post-1.92 form): gated items now render 'Available on crate feature X' badges on docs.rs. - Export V3StorageLayout/SolidlyStorageLayout at the adapters root unconditionally (both always compiled; V3StorageLayout is a root-exported metadata field type). Co-Authored-By: Claude Fable 5 --- Cargo.toml | 5 ++++- src/adapters/mod.rs | 7 +++++-- src/lib.rs | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fc8550b..6959d5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,9 +29,12 @@ exclude = [ ] # Build docs.rs with every feature enabled so the `uniswap-v3`-gated `v3_sync` -# module and the `experimental-protocols` identities render. +# module and the `experimental-protocols` identities render, and pass +# `--cfg docsrs` so `doc_auto_cfg` (see lib.rs) stamps feature badges on gated +# items. [package.metadata.docs.rs] all-features = true +rustdoc-args = ["--cfg", "docsrs"] # Standalone workspace root: keeps this crate from being absorbed by any # ancestor-directory workspace and gives it its own Cargo.lock. diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 533f9cd..fc943d5 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -76,8 +76,11 @@ pub use factory::{SolidlyFactory, SolidlyFactoryConfig}; pub use reactive::AmmReactiveHandler; pub use registry::{AdapterRegistry, RegistryError, SubscriptionSpec}; pub use sim::{SimConfig, SimError, SwapQuote, quote_via_call, quote_via_call_from}; -#[cfg(feature = "solidly-v2")] -pub use storage::SolidlyStorageLayout; +// Both layout types are always compiled (`storage` is feature-neutral); export +// them unconditionally — `V3StorageLayout` is the field type of the +// root-exported `V3Metadata.storage_layout`, and gating `SolidlyStorageLayout` +// on its adapter feature only hid the root path from mixed builds. +pub use storage::{SolidlyStorageLayout, V3StorageLayout}; pub use storage_sync::{ CALLDATA_SLOT_LOADER_CODE, StorageSyncEncoding, StorageSyncError, StorageSyncSnapshot, StorageSyncSpec, build_calldata_slot_loader_program, build_slot_loader_program, diff --git a/src/lib.rs b/src/lib.rs index dbf6964..b5488dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,29 @@ // item so the surface stays fully documented as it grows (CI's `-D warnings` // promotes this to an error). #![warn(missing_docs)] +// docs.rs builds with `--cfg docsrs` (see `[package.metadata.docs.rs]`), which +// enables nightly `doc_cfg`: every feature-gated item renders with its +// "Available on crate feature … only" badge, derived automatically from the +// existing `#[cfg]`s with no per-item annotations (auto-cfg is part of +// `doc_cfg` since 1.92, rust-lang/rust#138907). Inert on stable builds. +#![cfg_attr(docsrs, feature(doc_cfg))] + +/// Re-export of [`alloy_primitives`]: the `Address` / `U256` / `B256` / `Log` +/// vocabulary this crate's API speaks. +/// +/// Import from here (`evm_amm_state::alloy_primitives`) to use exactly the +/// version this crate's signatures expect without pinning `alloy-primitives` +/// yourself. +pub use alloy_primitives; +/// Re-export of the [`evm_fork_cache`] companion crate: `EvmCache`, the +/// reactive runtime, storage programs, and the typed errors that appear on +/// this crate's driver seam. +/// +/// `evm-fork-cache` is a 0.x **public dependency**: a semver-breaking bump +/// there (e.g. 0.2 → 0.3) is necessarily a breaking release of this crate too, +/// and the two are released in lockstep. Importing it through this re-export +/// (`evm_amm_state::evm_fork_cache`) guarantees the versions match. +pub use evm_fork_cache; // Always compiled — the adapter layer has no heavy deps. pub mod adapters; From 670ccabc36702418fb2f0f990e15f93b548d2016 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Wed, 8 Jul 2026 09:44:02 +0100 Subject: [PATCH 03/14] feat: close the remaining non_exhaustive gaps on the mirror surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #[non_exhaustive] on the cold_start/bytecode/state mirror structs (ColdStartPlan and friends) so upstream field additions — e.g. the pending root-probe planner phase — land without breaking downstream; each type documents its construction path and gained a constructor (new/with_*) where Default + assignment wasn't enough. - BytecodeTemplateError joins the crate's other error enums as non_exhaustive; SlotDelta documents why it deliberately stays exhaustive. - Fix the find_many doc-merge artifact (stale internal doc block was the rustdoc summary line) and the stale 'from = ZERO' claim on AmmAdapter::simulate_swap (SimConfig::from is threaded through since tier-2). - Tests/examples switch to the new constructors (external crates can no longer literal-construct the mirrors — that is the point). Co-Authored-By: Claude Fable 5 --- examples/verified_bytecode_seed.rs | 23 ++++----- src/adapters/bytecode.rs | 65 +++++++++++++++++++++++++ src/adapters/cold_start.rs | 76 ++++++++++++++++++++++++++++++ src/adapters/factory.rs | 34 ++++--------- src/adapters/state.rs | 49 +++++++++++++++++++ src/adapters/traits.rs | 3 +- tests/adapter_a1.rs | 37 ++++----------- tests/bytecode_golden.rs | 22 ++++----- tests/code_seed_semantics.rs | 6 +-- tests/cold_start_adoption.rs | 21 ++++----- tests/discovery.rs | 7 +-- tests/discovery_cl.rs | 7 +-- tests/discovery_solidly.rs | 7 +-- 13 files changed, 245 insertions(+), 112 deletions(-) diff --git a/examples/verified_bytecode_seed.rs b/examples/verified_bytecode_seed.rs index 8e442fc..8a812ac 100644 --- a/examples/verified_bytecode_seed.rs +++ b/examples/verified_bytecode_seed.rs @@ -51,19 +51,16 @@ async fn main() -> Result<()> { cache.seed_account_code(v2_seed.address, v2_seed.runtime_bytecode)?; let v3_tick_spacing = 10; - let v3_seed = uniswap_v3_code_seed( - UNISWAP_V3_USDC_WETH_500, - &V3ImmutablePatchValues { - pool_address: Some(UNISWAP_V3_USDC_WETH_500), - factory: Some(CANONICAL_UNISWAP_V3_FACTORY), - token0: Some(USDC), - token1: Some(WETH), - fee: Some(500), - tick_spacing: Some(v3_tick_spacing), - max_liquidity_per_tick: uniswap_v3_max_liquidity_per_tick(v3_tick_spacing), - }, - ) - .context("render Uniswap V3 code seed from explicit immutable values")?; + let mut v3_immutables = V3ImmutablePatchValues::default() + .with_pool_address(UNISWAP_V3_USDC_WETH_500) + .with_factory(CANONICAL_UNISWAP_V3_FACTORY) + .with_token0(USDC) + .with_token1(WETH) + .with_fee(500) + .with_tick_spacing(v3_tick_spacing); + v3_immutables.max_liquidity_per_tick = uniswap_v3_max_liquidity_per_tick(v3_tick_spacing); + let v3_seed = uniswap_v3_code_seed(UNISWAP_V3_USDC_WETH_500, &v3_immutables) + .context("render Uniswap V3 code seed from explicit immutable values")?; let v3_len = v3_seed.runtime_bytecode.len(); let v3_hash = v3_seed.code_hash; cache.seed_account_code(v3_seed.address, v3_seed.runtime_bytecode)?; diff --git a/src/adapters/bytecode.rs b/src/adapters/bytecode.rs index ddc9a01..7331b41 100644 --- a/src/adapters/bytecode.rs +++ b/src/adapters/bytecode.rs @@ -87,6 +87,10 @@ const UNISWAP_V3_IMMUTABLE_PATCHES: V3ImmutablePatches = V3ImmutablePatches { }; /// Runtime bytecode to seed for one on-chain account before cold-start. +/// +/// `#[non_exhaustive]`: Construct via [`AdapterCodeSeed::new`] or +/// [`AdapterCodeSeed::with_code_hash`]. +#[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub struct AdapterCodeSeed { /// Account whose canonical runtime bytecode is being seeded. @@ -224,6 +228,10 @@ fn runtime_code_hash(runtime_bytecode: &Bytes) -> B256 { } /// A byte range in deployed runtime bytecode occupied by one immutable value. +/// +/// `#[non_exhaustive]`: Construct via [`BytecodePatch::new`] (`const`, so `static` patch tables +/// keep working). +#[non_exhaustive] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct BytecodePatch { /// Byte offset into the runtime bytecode. @@ -243,6 +251,9 @@ impl BytecodePatch { /// /// Each field lists the byte ranges in the template occupied by that Solidity /// immutable, patched per-pool at render time. +/// +/// `#[non_exhaustive]`: Construct via `Default` plus the `with_*` builders. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct V3ImmutablePatches { /// Ranges holding the pool's own address (`NoDelegateCall` self-address). @@ -306,6 +317,9 @@ impl V3ImmutablePatches { } /// A V3-style pool runtime template plus immutable patch locations. +/// +/// `#[non_exhaustive]`: Construct via [`V3RuntimeBytecodeTemplate::new`]. +#[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub struct V3RuntimeBytecodeTemplate { /// Deployed runtime bytecode before per-pool immutable replacement. @@ -371,6 +385,10 @@ impl V3RuntimeBytecodeTemplate { } /// Per-pool immutable values used to render a V3 runtime bytecode template. +/// +/// `#[non_exhaustive]`: Construct via `Default` plus the `with_*` builders (fields stay `pub` +/// for direct assignment). +#[non_exhaustive] #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct V3ImmutablePatchValues { /// The pool's own address. @@ -389,7 +407,54 @@ pub struct V3ImmutablePatchValues { pub max_liquidity_per_tick: Option, } +impl V3ImmutablePatchValues { + /// Set the pool's own address. + pub fn with_pool_address(mut self, pool_address: Address) -> Self { + self.pool_address = Some(pool_address); + self + } + + /// Set the factory/deployer address. + pub fn with_factory(mut self, factory: Address) -> Self { + self.factory = Some(factory); + self + } + + /// Set `token0`. + pub fn with_token0(mut self, token0: Address) -> Self { + self.token0 = Some(token0); + self + } + + /// Set `token1`. + pub fn with_token1(mut self, token1: Address) -> Self { + self.token1 = Some(token1); + self + } + + /// Set the pool fee. + pub fn with_fee(mut self, fee: u32) -> Self { + self.fee = Some(fee); + self + } + + /// Set the tick spacing. + pub fn with_tick_spacing(mut self, tick_spacing: i32) -> Self { + self.tick_spacing = Some(tick_spacing); + self + } + + /// Set `maxLiquidityPerTick`. + pub fn with_max_liquidity_per_tick(mut self, max_liquidity_per_tick: U256) -> Self { + self.max_liquidity_per_tick = Some(max_liquidity_per_tick); + self + } +} + /// Why rendering a V3 runtime bytecode template failed. +/// +/// `#[non_exhaustive]` — an open error vocabulary. +#[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub enum BytecodeTemplateError { /// A patch range was declared for `field` but no value was supplied. diff --git a/src/adapters/cold_start.rs b/src/adapters/cold_start.rs index f53ec1c..0a1e0ff 100644 --- a/src/adapters/cold_start.rs +++ b/src/adapters/cold_start.rs @@ -55,6 +55,9 @@ use super::v3_sync::{V3SyncError, V3SyncSpec, decode_full_sync, full_sync_progra /// so the pool falls back to lazily fetching its real code. /// /// [`ColdStartReport`]: super::ColdStartReport +/// +/// `#[non_exhaustive]`: Construct via `Default` and field assignment. +#[non_exhaustive] #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CodeSeedReport { /// Seeds confirmed against on-chain code (`CodeSeedState::Verified`). @@ -85,6 +88,9 @@ impl From for CodeSeedReport { /// One contradicted code-seed claim from verification. /// /// Crate-owned mirror of [`evm_fork_cache::cache::CodeMismatch`]. +/// +/// `#[non_exhaustive]`: Construct via [`CodeSeedMismatch::new`]. +#[non_exhaustive] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct CodeSeedMismatch { /// The seeded address. @@ -95,6 +101,17 @@ pub struct CodeSeedMismatch { pub actual: B256, } +impl CodeSeedMismatch { + /// A mismatch record: `address` claimed `expected`, the chain holds `actual`. + pub fn new(address: Address, expected: B256, actual: B256) -> Self { + Self { + address, + expected, + actual, + } + } +} + impl From for CodeSeedMismatch { fn from(mismatch: evm_fork_cache::cache::CodeMismatch) -> Self { Self { @@ -110,6 +127,10 @@ impl From for CodeSeedMismatch { /// /// Crate-owned mirror of [`evm_fork_cache::cold_start::ColdStartPlan`]. All four /// phases are optional; an empty plan is a valid no-op round. +/// +/// `#[non_exhaustive]`: Construct via `Default` and field assignment, so future phases (e.g. the +/// upstream root-probe baseline) can land without breaking planner authors. +#[non_exhaustive] #[derive(Clone, Debug, Default)] pub struct ColdStartPlan { /// Slots to authoritatively re-fetch, classify, and inject when changed. @@ -140,6 +161,9 @@ impl From for evm_fork_cache::cold_start::ColdStartPlan { /// the discover phase. /// /// Crate-owned mirror of [`evm_fork_cache::cold_start::ColdStartCall`]. +/// +/// `#[non_exhaustive]`: Construct via [`ColdStartCall::new`]. +#[non_exhaustive] #[derive(Clone, Debug)] pub struct ColdStartCall { /// Transaction sender. @@ -152,6 +176,25 @@ pub struct ColdStartCall { pub restrict_to: Option>, } +impl ColdStartCall { + /// A discover view-call from `from` to `to` with `calldata`, capturing every + /// touched slot and account (no `restrict_to` filter). + pub fn new(from: Address, to: Address, calldata: impl Into) -> Self { + Self { + from, + to, + calldata: calldata.into(), + restrict_to: None, + } + } + + /// Filter the captured slots and accounts to `addresses`. + pub fn with_restrict_to(mut self, addresses: impl IntoIterator) -> Self { + self.restrict_to = Some(addresses.into_iter().collect()); + self + } +} + impl From for evm_fork_cache::cold_start::ColdStartCall { fn from(call: ColdStartCall) -> Self { evm_fork_cache::cold_start::ColdStartCall { @@ -169,6 +212,9 @@ impl From for evm_fork_cache::cold_start::ColdStartCall { /// `fetched` / `probed` carry one [`SlotOutcome`] per declared verify / probe /// slot; `verified` carries only the slots whose value changed; `discovered` /// carries one [`ColdStartCallResult`] per discover call. +/// +/// `#[non_exhaustive]`: Construct via `Default` and field assignment. +#[non_exhaustive] #[derive(Clone, Debug, Default)] pub struct ColdStartResults { /// Slots whose value changed and were injected (one per change). @@ -200,6 +246,9 @@ impl From for ColdStartResults { /// the storage/account access list it touched. /// /// Crate-owned mirror of [`evm_fork_cache::cold_start::ColdStartCallResult`]. +/// +/// `#[non_exhaustive]`: Construct via [`ColdStartCallResult::new`]. +#[non_exhaustive] #[derive(Clone, Debug)] pub struct ColdStartCallResult { /// The classified outcome of the view-call. @@ -208,6 +257,13 @@ pub struct ColdStartCallResult { pub access: StorageAccessList, } +impl ColdStartCallResult { + /// A discover-call result from its classified outcome and access list. + pub fn new(result: CallOutcome, access: StorageAccessList) -> Self { + Self { result, access } + } +} + impl From for ColdStartCallResult { fn from(call: evm_fork_cache::cold_start::ColdStartCallResult) -> Self { Self { @@ -221,6 +277,9 @@ impl From for ColdStartCallResu /// /// Crate-owned mirror of `evm_fork_cache`'s `StorageAccessList` (the access-set /// surface a discover call captures). +/// +/// `#[non_exhaustive]`: Construct via `Default` and field assignment. +#[non_exhaustive] #[derive(Clone, Debug, Default)] pub struct StorageAccessList { /// Accounts the call touched. @@ -275,6 +334,9 @@ impl From for SlotFetch { /// Crate-owned mirror of `evm_fork_cache`'s `SlotOutcome`: produced for **every** /// requested verify / probe slot (unlike [`SlotChange`], which records only /// changed slots). +/// +/// `#[non_exhaustive]`: Construct via [`SlotOutcome::new`]. +#[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub struct SlotOutcome { /// Contract whose storage slot was fetched. @@ -285,6 +347,17 @@ pub struct SlotOutcome { pub fetch: SlotFetch, } +impl SlotOutcome { + /// The classified outcome of fetching `slot` on `address`. + pub fn new(address: Address, slot: U256, fetch: SlotFetch) -> Self { + Self { + address, + slot, + fetch, + } + } +} + impl From for SlotOutcome { fn from(outcome: evm_fork_cache::cold_start::SlotOutcome) -> Self { Self { @@ -323,6 +396,9 @@ impl From for evm_fork_cache::cold_start::ColdStartStep { /// /// Crate-owned mirror of [`evm_fork_cache::cold_start::ColdStartRunReport`], /// carrying the accumulated per-run counters. +/// +/// `#[non_exhaustive]`: Construct via `Default` and field assignment. +#[non_exhaustive] #[derive(Clone, Debug, Default)] pub struct ColdStartRunReport { /// Number of rounds executed. diff --git a/src/adapters/factory.rs b/src/adapters/factory.rs index 33ba18c..abddc8a 100644 --- a/src/adapters/factory.rs +++ b/src/adapters/factory.rs @@ -1316,35 +1316,21 @@ impl PoolDiscovery { self.factories.push(factory); } - /// Shared batched resolution core behind [`find`](Self::find). - /// - /// Considers only factories matching `protocol` (all factories when - /// `protocol` is `None`) and partitions them into two groups by whether they - /// opt into batched discovery: - /// - /// - *Batchable* factories (non-empty [`candidate_reads`]) contribute their - /// candidate slots to a single shared set. The whole set is de-duplicated - /// and resolved with exactly ONE [`AdapterCache::read_storage_slots`] call - /// — regardless of factory or pair count — then handed back to each - /// factory's [`assemble_pairs`] to build its pools. The built-in Uniswap V2 - /// and V3 factories are batchable. - /// - *Legacy* factories (default empty [`candidate_reads`], i.e. they only - /// implement [`find_pools`]) fall back to a per-pair [`find_pools`] call, so - /// externally-implemented factories keep working. - /// - /// [`candidate_reads`]: PoolFactory::candidate_reads - /// [`assemble_pairs`]: PoolFactory::assemble_pairs - /// [`find_pools`]: PoolFactory::find_pools /// Discover pools for several [`PoolQuery`]s at once, resolving the candidate /// slots of *all* of them in a single batched read and returning the - /// de-duplicated union. + /// de-duplicated union. This is the shared resolution core behind + /// [`find`](Self::find). /// /// Each query is scoped independently, so one call can mix protocols across /// pairs — some pairs only on Uniswap V2, others only on V3 — without extra - /// round-trips. Batchable factories (the built-in V2/V3) contribute their - /// candidate mapping slots to ONE [`AdapterCache::read_storage_slots`] call - /// (a single bulk `eth_call` on an `EvmCache`); external factories that only - /// implement [`find_pools`](PoolFactory::find_pools) fall back per pair. A + /// round-trips. *Batchable* factories (non-empty + /// [`candidate_reads`](PoolFactory::candidate_reads); the built-in V2/V3) + /// contribute their candidate mapping slots to ONE + /// [`AdapterCache::read_storage_slots`] call — a single bulk `eth_call` on an + /// `EvmCache`, regardless of factory or pair count — then each factory's + /// [`assemble_pairs`](PoolFactory::assemble_pairs) builds its pools from the + /// shared answers. External factories that only implement + /// [`find_pools`](PoolFactory::find_pools) fall back per pair. A /// query scoped with [`PoolQuery::on`] to a protocol with no registered /// factory yields [`DiscoveryError::MissingFactory`]; an empty query list is /// `Ok(vec![])`. diff --git a/src/adapters/state.rs b/src/adapters/state.rs index 2f5352e..e114b26 100644 --- a/src/adapters/state.rs +++ b/src/adapters/state.rs @@ -19,6 +19,11 @@ use alloy_primitives::{Address, U256}; /// /// Crate-owned mirror of [`evm_fork_cache::SlotDelta`]. Both directions /// **saturate**: `Add` clamps at `U256::MAX`, `Sub` at `U256::ZERO`. +/// +/// Deliberately exhaustive (not `#[non_exhaustive]`): `Add`/`Sub` is the +/// complete relative-mutation vocabulary, and matching both is semantically +/// meaningful for consumers (e.g. inverting a delta). A genuinely new mutation +/// kind would change apply semantics and warrants a breaking release. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SlotDelta { /// Add to the current value, saturating at `U256::MAX`. @@ -192,6 +197,9 @@ impl From for evm_fork_cache::PurgeScope { /// (`ZERO` if previously uncached), `new` is the resulting value. /// /// Crate-owned mirror of [`evm_fork_cache::SlotChange`]. +/// +/// `#[non_exhaustive]`: Construct via [`SlotChange::new`]. +#[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub struct SlotChange { /// Contract whose storage changed. @@ -204,6 +212,18 @@ pub struct SlotChange { pub new: U256, } +impl SlotChange { + /// A change record for `slot` on `address`: `old` -> `new`. + pub fn new(address: Address, slot: U256, old: U256, new: U256) -> Self { + Self { + address, + slot, + old, + new, + } + } +} + impl From for SlotChange { fn from(change: evm_fork_cache::SlotChange) -> Self { Self { @@ -219,6 +239,9 @@ impl From for SlotChange { /// because the slot's current value is unknown (cold). /// /// Crate-owned mirror of [`evm_fork_cache::SkippedDelta`]. +/// +/// `#[non_exhaustive]`: Construct via [`SkippedDelta::new`]. +#[non_exhaustive] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SkippedDelta { /// Contract whose storage slot the delta targeted. @@ -229,6 +252,17 @@ pub struct SkippedDelta { pub delta: SlotDelta, } +impl SkippedDelta { + /// A skipped-delta record for cold `slot` on `address`. + pub fn new(address: Address, slot: U256, delta: SlotDelta) -> Self { + Self { + address, + slot, + delta, + } + } +} + impl From for SkippedDelta { fn from(skipped: evm_fork_cache::SkippedDelta) -> Self { Self { @@ -243,6 +277,9 @@ impl From for SkippedDelta { /// the target slot's current value is unknown (cold). /// /// Crate-owned mirror of [`evm_fork_cache::SkippedMask`]. +/// +/// `#[non_exhaustive]`: Construct via [`SkippedMask::new`]. +#[non_exhaustive] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SkippedMask { /// Contract whose storage slot the masked write targeted. @@ -255,6 +292,18 @@ pub struct SkippedMask { pub value: U256, } +impl SkippedMask { + /// A skipped-mask record for cold `slot` on `address`. + pub fn new(address: Address, slot: U256, mask: U256, value: U256) -> Self { + Self { + address, + slot, + mask, + value, + } + } +} + impl From for SkippedMask { fn from(skipped: evm_fork_cache::SkippedMask) -> Self { Self { diff --git a/src/adapters/traits.rs b/src/adapters/traits.rs index 896abd4..4ce9d03 100644 --- a/src/adapters/traits.rs +++ b/src/adapters/traits.rs @@ -123,7 +123,8 @@ pub trait AmmAdapter: Send + Sync { /// returning the protocol's canonical `amount_out`. /// /// The implementation builds the protocol's canonical *quote* calldata and - /// runs it via [`AdapterCache::call_raw`] with `from = ZERO`, + /// runs it via [`AdapterCache::call_raw`] with `from = config.from` (default + /// `ZERO`, see [`SimConfig::from`]), /// `to = `, `commit = false` against the cold-start snapshot, /// then decodes `amount_out` from the [`ExecutionResult`] output. The /// deployed contract bytecode does the AMM math — there is no `amm-math` / diff --git a/tests/adapter_a1.rs b/tests/adapter_a1.rs index bd0e0cb..5affeef 100644 --- a/tests/adapter_a1.rs +++ b/tests/adapter_a1.rs @@ -59,12 +59,8 @@ impl AdapterCache for MockCache { .insert((*address, *slot), *value) .unwrap_or_default(); if old != *value { - diff.slots.push(SlotChange { - address: *address, - slot: *slot, - old, - new: *value, - }); + diff.slots + .push(SlotChange::new(*address, *slot, old, *value)); } } StateUpdate::SlotMasked { @@ -77,20 +73,11 @@ impl AdapterCache for MockCache { let new = (old & !*mask) | (*value & *mask); self.storage.insert((*address, *slot), new); if old != new { - diff.slots.push(SlotChange { - address: *address, - slot: *slot, - old, - new, - }); + diff.slots.push(SlotChange::new(*address, *slot, old, new)); } } else { - diff.skipped_masks.push(SkippedMask { - address: *address, - slot: *slot, - mask: *mask, - value: *value, - }); + diff.skipped_masks + .push(SkippedMask::new(*address, *slot, *mask, *value)); } } StateUpdate::SlotDelta { @@ -102,19 +89,11 @@ impl AdapterCache for MockCache { let new = delta.apply(old); self.storage.insert((*address, *slot), new); if old != new { - diff.slots.push(SlotChange { - address: *address, - slot: *slot, - old, - new, - }); + diff.slots.push(SlotChange::new(*address, *slot, old, new)); } } else { - diff.skipped.push(SkippedDelta { - address: *address, - slot: *slot, - delta: *delta, - }); + diff.skipped + .push(SkippedDelta::new(*address, *slot, *delta)); } } StateUpdate::Purge { address, .. } => { diff --git a/tests/bytecode_golden.rs b/tests/bytecode_golden.rs index cbb9a09..a2390a9 100644 --- a/tests/bytecode_golden.rs +++ b/tests/bytecode_golden.rs @@ -52,19 +52,15 @@ fn assert_v3_render( tick_spacing: i32, expected: B256, ) { - let seed = uniswap_v3_code_seed( - pool, - &V3ImmutablePatchValues { - pool_address: Some(pool), - factory: Some(CANONICAL_V3_FACTORY), - token0: Some(token0), - token1: Some(token1), - fee: Some(fee), - tick_spacing: Some(tick_spacing), - max_liquidity_per_tick: uniswap_v3_max_liquidity_per_tick(tick_spacing), - }, - ) - .expect("render V3 template"); + let mut immutables = V3ImmutablePatchValues::default() + .with_pool_address(pool) + .with_factory(CANONICAL_V3_FACTORY) + .with_token0(token0) + .with_token1(token1) + .with_fee(fee) + .with_tick_spacing(tick_spacing); + immutables.max_liquidity_per_tick = uniswap_v3_max_liquidity_per_tick(tick_spacing); + let seed = uniswap_v3_code_seed(pool, &immutables).expect("render V3 template"); assert_eq!( seed.code_hash, expected, "rendered V3 runtime for {pool:?} (fee={fee}, tickSpacing={tick_spacing}) \ diff --git a/tests/code_seed_semantics.rs b/tests/code_seed_semantics.rs index fd8ec2c..3123631 100644 --- a/tests/code_seed_semantics.rs +++ b/tests/code_seed_semantics.rs @@ -373,11 +373,7 @@ async fn code_verification_results_are_surfaced_in_report() -> Result<()> { assert_eq!(seeds.mismatched.len(), 1, "the mismatch must be reported"); assert_eq!( seeds.mismatched[0], - CodeSeedMismatch { - address: pool, - expected: uniswap_v2_pair_runtime_code_hash(), - actual: wrong_hash, - } + CodeSeedMismatch::new(pool, uniswap_v2_pair_runtime_code_hash(), wrong_hash) ); Ok(()) } diff --git a/tests/cold_start_adoption.rs b/tests/cold_start_adoption.rs index 7d0a2d1..6905773 100644 --- a/tests/cold_start_adoption.rs +++ b/tests/cold_start_adoption.rs @@ -444,18 +444,15 @@ async fn v3_bytecode_template_patches_immutables_with_explicit_factory() -> Resu let token1 = Address::repeat_byte(0xc3); let fee = 500u32; let tick_spacing = 60; - let seed = uniswap_v3_code_seed( - pool, - &V3ImmutablePatchValues { - pool_address: Some(pool), - factory: Some(factory), - token0: Some(token0), - token1: Some(token1), - fee: Some(fee), - tick_spacing: Some(tick_spacing), - max_liquidity_per_tick: uniswap_v3_max_liquidity_per_tick(tick_spacing), - }, - )?; + let mut immutables = V3ImmutablePatchValues::default() + .with_pool_address(pool) + .with_factory(factory) + .with_token0(token0) + .with_token1(token1) + .with_fee(fee) + .with_tick_spacing(tick_spacing); + immutables.max_liquidity_per_tick = uniswap_v3_max_liquidity_per_tick(tick_spacing); + let seed = uniswap_v3_code_seed(pool, &immutables)?; let expected_hash = seed.code_hash; let mut cache = setup_cache().await?; diff --git a/tests/discovery.rs b/tests/discovery.rs index bfaf2d0..1d0535a 100644 --- a/tests/discovery.rs +++ b/tests/discovery.rs @@ -53,11 +53,8 @@ impl AdapterCache for CountingCache { fn verify_slots(&mut self, slots: &[(Address, U256)]) -> Result, CacheError> { Ok(slots .iter() - .map(|(a, s)| SlotChange { - address: *a, - slot: *s, - old: U256::ZERO, - new: self.storage(*a, *s).unwrap_or_default(), + .map(|(a, s)| { + SlotChange::new(*a, *s, U256::ZERO, self.storage(*a, *s).unwrap_or_default()) }) .collect()) } diff --git a/tests/discovery_cl.rs b/tests/discovery_cl.rs index 75ea075..f57e2c6 100644 --- a/tests/discovery_cl.rs +++ b/tests/discovery_cl.rs @@ -51,11 +51,8 @@ impl AdapterCache for CountingCache { fn verify_slots(&mut self, slots: &[(Address, U256)]) -> Result, CacheError> { Ok(slots .iter() - .map(|(a, s)| SlotChange { - address: *a, - slot: *s, - old: U256::ZERO, - new: self.storage(*a, *s).unwrap_or_default(), + .map(|(a, s)| { + SlotChange::new(*a, *s, U256::ZERO, self.storage(*a, *s).unwrap_or_default()) }) .collect()) } diff --git a/tests/discovery_solidly.rs b/tests/discovery_solidly.rs index 4084dbc..cc33118 100644 --- a/tests/discovery_solidly.rs +++ b/tests/discovery_solidly.rs @@ -42,11 +42,8 @@ impl AdapterCache for CountingCache { fn verify_slots(&mut self, slots: &[(Address, U256)]) -> Result, CacheError> { Ok(slots .iter() - .map(|(a, s)| SlotChange { - address: *a, - slot: *s, - old: U256::ZERO, - new: self.storage(*a, *s).unwrap_or_default(), + .map(|(a, s)| { + SlotChange::new(*a, *s, U256::ZERO, self.storage(*a, *s).unwrap_or_default()) }) .collect()) } From 770d6f9bedef4eacfa29eeea48f37302b216257d Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Wed, 8 Jul 2026 09:46:41 +0100 Subject: [PATCH 04/14] =?UTF-8?q?docs:=20truth=20pass=20=E2=80=94=20event-?= =?UTF-8?q?sourcing=20reality=20for=20Balancer/V3,=20drop=20ghost=20ViewCa?= =?UTF-8?q?ll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last merged PRs (#30/#31/#33) outran four shipped docs: CHANGELOG, README (intro + protocol table), protocol-support-matrix, and trace-backed-sync still described Balancer Swap (and partly V3 Mint/Burn) as resync-only, and the changelog omitted Balancer swap event-sourcing, the verify-only cold-start fast path, CurveMetadata::with_code_seed, the Balancer/Curve cold_start_many bundling, and the new stateDiff parity suites. Also removes the MetaRegistry/ViewCall discovery mechanism that 3a197f3 dropped from code but three docs kept describing, and the unused Cargo.toml link def. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 45 ++++++++++++++++++++++----------- README.md | 15 ++++++----- docs/pool-discovery.md | 9 ++++--- docs/protocol-support-matrix.md | 7 ++--- docs/trace-backed-sync.md | 5 ++-- 5 files changed, 50 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42f70d9..128c0f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,13 +82,20 @@ register → cold-start → subscribe → react → simulate. 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. + (`getPoolTokens` read-set), with a **verify-only fast path** when the + read-set is already known; `Swap` → **event-sourced** exact 112-bit + `cash`-field writes where the probed cash locations are warm (TWO_TOKEN and + GENERAL specializations), balance-slot resync as fallback; + `PoolBalanceChanged` → balance-slot resync. - **Solidly V2** (Aerodrome / Velodrome) — pool `getAmountOut` quotes; config-supplied storage layout; `Sync` → two exact slot writes. - **Curve** — StableSwap, StableSwap-NG, CryptoSwap v2, and Tricrypto-NG dialects through one adapter; pool `get_dy` quotes; discover→verify - cold-start; `TokenExchange` + liquidity events → discovered-slot resync. - Event signatures and `get_dy` ABIs verified on-chain per dialect. + cold-start, with a **verify-only fast path** when `discovered_slots` is + pre-populated and an optional caller-supplied bytecode seed + (`CurveMetadata::with_code_seed`); `TokenExchange` + liquidity events → + discovered-slot resync. Event signatures and `get_dy` ABIs verified on-chain + per dialect. **Simulation** — `simulate_swap` runs each pool's **own** canonical on-chain quote entrypoint inside a local revm against the warmed cache, then decodes the @@ -98,9 +105,11 @@ result. There is **no reimplemented AMM math**. 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`). +and resyncing only ticks outside the warmed window; Balancer V2 `Swap`s +event-source the vault's packed 112-bit `cash` fields directly when the probed +cash locations are warm, falling back to a slot resync on gaps; Curve events +(and Balancer joins/exits) carry deltas over a non-predictable layout and +re-verify the discovered slots (`VerifySlots`). **Testing & CI** @@ -108,8 +117,12 @@ deltas over a non-predictable layout and re-verify the discovered slots react→simulate pipeline tests. - Env-gated, `#[ignore]`d network tests: RPC parity (fork at a pinned block, cold-start a real pool, assert `simulate_swap` == on-chain `eth_call` quote — - mainnet pools plus a Base pool for Solidly) and a live WebSocket soak that - keeps state in sync from events only. + mainnet pools plus a Base pool for Solidly), a live WebSocket soak that + keeps state in sync from events only, and per-transaction **write parity** + for the event-sourced paths: for real add/remove-liquidity and vault-swap + transactions, the adapter's writes are asserted equal to the on-chain + `trace_replayTransaction` storage diff (`tests/v3_liquidity_rpc.rs`, + `tests/balancer_liquidity_rpc.rs`). - CI runs fmt, clippy (all-features + a **per-protocol isolation matrix** + no-default-features), tests (all-features / default / no-default), doc (`-D warnings`), and a heavy-dependency leak guard. @@ -129,7 +142,10 @@ pool's canonical runtime bytecode into `EvmCache` at cold-start (via the on-chain `EXTCODEHASH` instead of paying an `eth_getCode`. Uniswap V2 shares one embedded pair runtime across every pair; Uniswap V3 patches the pool's Solidity immutables (factory, token0/1, fee, tickSpacing, maxLiquidityPerTick, -and the `NoDelegateCall` self-address) into an embedded template. Seeding is a +and the `NoDelegateCall` self-address) into an embedded template. Curve has no +shared template, but a caller that already knows a pool's Vyper runtime can +attach it with `CurveMetadata::with_code_seed` — verified once against on-chain +code under the same purge-on-mismatch contract. Seeding is a pure optimization: a hash mismatch, an unverifiable seed, a warm-cache code conflict, or a template render error all degrade to lazily fetching the real code — never a fatal error or a permanently `Degraded` pool — and every seeded @@ -142,10 +158,9 @@ offsets are pinned to chain-truth code hashes across tickSpacings 1/10/60 **Factory-backed pool discovery (`adapters::factory`)** — build cold-start-ready `PoolRegistration`s from configured factories instead of pasted -addresses, across every protocol whose pools resolve through the pinned cache. -Two discovery mechanisms: a **DerivedSlot** read (a Rust-computed factory -storage slot, resolved in the batched read) and a **ViewCall** (an on-chain -`view` executed in revm via `AdapterCache::call_raw`). Coverage: +addresses, across every protocol whose pools resolve through the pinned cache +via a **DerivedSlot** read — a Rust-computed factory storage slot, resolved in +the batched read. Coverage: - **Concentrated liquidity** — one generalized `ClFactorySpec` drives the whole UniV3-mechanics family through a single `ConcentratedLiquidityFactory`: fee-keyed @@ -200,7 +215,8 @@ registrations, and callers still decide when to cold-start them. default for warming many pools at once: it seeds + verifies every one-shot-eligible pool's code in one account-fields call, hydrates them all through a single bundled `run_storage_programs` `eth_call` (V3 full-sync / V2 -flat-slot), and finalizes `Ready` — falling back per pool to the normal +flat-slot / Balancer and Curve discovered read-sets — a discover→verify pool +qualifies once its `discovered_slots` are known), and finalizes `Ready` — falling back per pool to the normal `cold_start` for anything without a one-shot program or whose hydration fails. `supports_one_shot_hydration` reports eligibility. `examples/factory_discovery_live.rs` uses the `find(PoolQuery) → cold_start_many → register` path. @@ -265,7 +281,6 @@ uses the `find(PoolQuery) → cold_start_many → register` path. is rebuildable on top of `simulate_swap`. [`evm-fork-cache`]: https://github.com/KaiCode2/evm-fork-cache -[`Cargo.toml`]: Cargo.toml [`AmmAdapter`]: src/adapters/traits.rs [Unreleased]: https://github.com/KaiCode2/evm-amm-state/compare/v0.1.0...HEAD [0.1.0]: https://github.com/KaiCode2/evm-amm-state/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 23237ec..05431e1 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,12 @@ `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, 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 +events**: events that carry absolute state (Uniswap V2 / Solidly `Sync`) are +applied as exact writes with **no RPC at all**, Uniswap V3 `Mint`/`Burn` and +Balancer vault `Swap`s are **event-sourced** onto warm tick / cash slots the +same way, and only genuinely cold slots and delta-only events (Curve, Balancer +joins/exits) turn 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 @@ -47,7 +48,7 @@ Each protocol is a single [`AmmAdapter`] implementation; the | --- | --- | --- | --- | --- | | 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` → exact tick + global-liquidity writes where warm, resync only cold ticks | -| Balancer V2 | `balancer-v2` | `Vault.queryBatchSwap` | discover → verify (`getPoolTokens`) | `Swap` → balance-slot resync | +| Balancer V2 | `balancer-v2` | `Vault.queryBatchSwap` | discover → verify (`getPoolTokens`), verify-only once known | `Swap` → exact 112-bit cash writes where warm, resync fallback; `PoolBalanceChanged` → 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 | @@ -105,7 +106,7 @@ is empty by default: callers opt in with explicit factory addresses, e.g. fork-specific deployments never inherit an assumed factory. Discovery ships for every protocol whose pools resolve through the pinned -cache — a derived factory storage slot or a MetaRegistry view call: +cache as a derived factory storage slot, batched by default: | Protocol | Mechanism | | --- | --- | diff --git a/docs/pool-discovery.md b/docs/pool-discovery.md index f8f42ae..33ebbd4 100644 --- a/docs/pool-discovery.md +++ b/docs/pool-discovery.md @@ -71,10 +71,11 @@ and fork-specific deployments never inherit an assumed factory address. ## Protocol coverage -Each supported protocol resolves through the pinned cache by one of two -mechanisms: a **DerivedSlot** read (a Rust-computed factory storage slot, -resolved in the batched `read_storage_slots`) or a **ViewCall** (an on-chain -`view` executed in revm through [`AdapterCache::call_raw`]). +Each supported protocol resolves through the pinned cache by the same +mechanism: a **DerivedSlot** read — a Rust-computed factory storage slot, +resolved in the batched `read_storage_slots`. (An in-EVM view-call mechanism — +what Curve's Vyper MetaRegistry would need — is deliberately not built in; see +the protocol boundary below.) | Protocol | Mechanism | Notes | | --- | --- | --- | diff --git a/docs/protocol-support-matrix.md b/docs/protocol-support-matrix.md index 4f9b6ce..685f7e8 100644 --- a/docs/protocol-support-matrix.md +++ b/docs/protocol-support-matrix.md @@ -13,9 +13,9 @@ backend or extra setup is still needed. This complements the summary table in th | **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 | +| **Balancer V2** (`balancer-v2`) | discover→verify (`getPoolTokens` read-set); verify-only fast path once the read-set is known | ✅ (the vault's code is lazily fetched on the first quote) | `Swap` → **exact** 112-bit `cash`-field writes where the probed cash locations are warm (TWO_TOKEN + GENERAL specializations), **resync** fallback; `PoolBalanceChanged` → **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) | ✅ (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) | +| **Curve** — StableSwap, StableSwap-NG, CryptoSwap v2, Tricrypto-NG (`curve`) | discover→verify (`get_dy` read-set); verify-only fast path once the read-set is known | ✅ (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 @@ -28,7 +28,8 @@ a liquidity/swap event, resolved off the block's own trace where possible. - **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 + (Uniswap V2/V3 are; Curve accepts a caller-supplied seed via + `CurveMetadata::with_code_seed`; Balancer/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 diff --git a/docs/trace-backed-sync.md b/docs/trace-backed-sync.md index 9908c99..e94eceb 100644 --- a/docs/trace-backed-sync.md +++ b/docs/trace-backed-sync.md @@ -68,8 +68,9 @@ liquidity sync because it does not execute the resync phase. | Uniswap V2 `Sync` | exact masked reserve write | | Solidly V2 `Sync` | exact reserve writes when layout is configured | | V3 `Swap` | exact slot0/liquidity write | -| V3 `Mint`/`Burn` | resync computed tick, bitmap, and liquidity slots | -| Balancer V2 `Swap` | resync known Vault balance slots | +| V3 `Mint`/`Burn` | exact packed `liquidityGross`/`liquidityNet`, bitmap-bit, and in-range global-liquidity writes for warm (in-window) ticks; resync the computed tick/bitmap/liquidity slots only for cold ticks | +| Balancer V2 `Swap` | exact 112-bit `cash`-field writes when both tokens' probed cash locations are warm; resync known Vault balance slots otherwise | +| Balancer V2 `PoolBalanceChanged` | resync known Vault balance slots | | Curve swap/liquidity events | resync known pool read-set slots | The steady-state invariant is: a supported, ready pool either applies a log From eb4f3fd8ac0a9ec05b4e47af9c549217a2dbbb06 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Wed, 8 Jul 2026 09:48:55 +0100 Subject: [PATCH 05/14] docs: guide + benchmark accuracy fixes - writing-an-adapter: the AmmAdapter table was missing pool_factories and code_seeds and claimed 'six defaulted' methods (nine); custom_adapter's header repeated the six. - high-performance-bootstrap-defaults: past-tense the superseded 'currently' narrative (batched discovery + cold_start_many shipped) and mark checklist item 1 Done. - benchmarks.md: cargo bench does NOT emit HTML reports in this build (criterion without plotters; gnuplot only) and mean/median need --verbose; align the trace-resync reproduce command with the seven-iteration run it documents; stamp the results as the v0.1.0 candidate. - One auditable CU figure for the V3 full-sync comparison everywhere (7,674 reads x 17 CU ~= 130k; README/CHANGELOG said 153k, the example 134k). - bytecode_golden header: make explicit the file itself needs no env. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- README.md | 3 +- docs/benchmarks.md | 17 ++++++----- docs/high-performance-bootstrap-defaults.md | 32 ++++++++++++--------- docs/writing-an-adapter.md | 4 ++- examples/custom_adapter.rs | 4 +-- examples/v3_full_sync.rs | 3 +- tests/bytecode_golden.rs | 2 +- 8 files changed, 40 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 128c0f7..c1055ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ code via `evm-fork-cache`'s `eth_call` state-override transport: cold-start planner's rounds 2+3 collapsed into one call) and for chunking dense spacing-1 pools. - Live-verified on USDC/WETH 0.05%: 1,563 ticks + 723 observations → 7,674 - slots in ~140 ms / 26 CU (vs ~153k CU as point reads); tick-crossing quote + slots in ~140 ms / 26 CU (vs ~130k CU as per-slot point reads); tick-crossing quote parity against both a classically cold-started cache and the provider's QuoterV2 `eth_call` (`tests/v3_full_sync_rpc.rs`); offline revm execution suite for the generated bytecode (`tests/v3_sync.rs`); runnable demo diff --git a/README.md b/README.md index 05431e1..e7bac92 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,8 @@ injected over a pool's code via an `eth_call` state override bitmap *inside the EVM* — returning statics, every initialized tick's four info words, and the whole observation ring in **one call with zero calldata**. Live-measured on the USDC/WETH 0.05% pool: 1,563 ticks + 723 observations → -7,674 slots injected in ~140 ms for 26 CU (vs ~153k CU as point reads), after +7,674 slots injected in ~140 ms for 26 CU (vs ~130k CU as per-slot point +reads — 7,674 × 17 CU), after which a hard multi-tick-crossing quote runs in **~5 ms with zero lazy fetches** (vs ~2 s paging ticks over RPC on a windowed cache). A calldata-driven **partial** variant refreshes selected bitmap-word ranges diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 4efa1d7..7f74559 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -29,12 +29,15 @@ 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. +> Point-in-time medians from a single host and run (July 2026, the v0.1.0 +> release candidate, 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 Criterion's timing estimates +> (`time: [low mid high]` plus outlier counts) to stdout — add `-- --verbose` +> for mean/median/std-dev — and saves baseline data under `target/criterion/`. +> (This crate builds criterion without the `plotters` backend, so HTML reports +> are only generated if `gnuplot` is installed.) The medians below are the +> headline figures from that output. ### `simulate_swap` — one offline quote (the repeated hot path) @@ -147,7 +150,7 @@ profile after bootstrap. measures the live reactive repair path for a real Curve 3pool event: ```bash -E2E_RPC_URL= TRACE_RESYNC_ITERS=3 cargo run --release --example trace_resync_latency +E2E_RPC_URL= TRACE_RESYNC_ITERS=7 cargo run --release --example trace_resync_latency ``` The example finds a recent `TokenExchange`, cold-starts the pool at the previous diff --git a/docs/high-performance-bootstrap-defaults.md b/docs/high-performance-bootstrap-defaults.md index d866f20..1e4ae51 100644 --- a/docs/high-performance-bootstrap-defaults.md +++ b/docs/high-performance-bootstrap-defaults.md @@ -110,17 +110,21 @@ engine.register_pools([registration])?; That API is correct and conservative, but it is not the fastest available route. -Factory discovery currently resolves derived mapping slots one at a time: +At the time of this analysis, factory discovery resolved derived mapping slots +one at a time: -- V2 reads one `getPair[token0][token1]` slot. -- V3 loops fee tiers and reads one `getPool[token0][token1][fee]` slot per tier. -- For every found V3 pool it also reads `feeAmountTickSpacing[fee]`. +- V2 read one `getPair[token0][token1]` slot. +- V3 looped fee tiers and read one `getPool[token0][token1][fee]` slot per tier. +- For every found V3 pool it also read `feeAmountTickSpacing[fee]`. -For USDC/WETH this is nine derived-slot reads. These reads are cheap, but they -are not currently planned as one bulk watchlist. +For USDC/WETH this was nine derived-slot reads — cheap, but not planned as one +bulk watchlist. **Shipped since:** `PoolDiscovery::find` / `find_many` now +gather every factory's candidate slots (`PoolFactory::candidate_reads`) into a +single batched `read_storage_slots` call. -Cold-start currently runs one pool at a time through the generic cold-start -planner. V3 is necessarily dependent in this planner: +Cold-start, at the time of this analysis, ran one pool at a time through the +generic cold-start planner (the shipped `cold_start_many` now bundles +one-shot-eligible pools; the per-pool planner remains the fallback). V3 is necessarily dependent in this planner: 1. read `slot0` + global liquidity; 2. derive the current bitmap word window from `slot0`; @@ -129,9 +133,10 @@ planner. V3 is necessarily dependent in this planner: 5. read tick info slots. Even when the underlying `EvmCache` storage fetcher uses bulk extraction inside a -round, the high-level call shape still prevents collapsing all pools and all -known work into a single bootstrap plan. V3 cold-start also does not use the -existing one-shot V3 sync program. +round, the high-level call shape still prevented collapsing all pools and all +known work into a single bootstrap plan, and V3 cold-start did not yet use the +one-shot V3 sync program (both are addressed by `cold_start_many`; a unified +discovery-to-ready `Bootstrapper` remains future work, below). ## Existing fast primitives @@ -279,8 +284,9 @@ that must be visible in the returned plan/report, not hidden behind a slow call. ## Migration checklist -1. Add a bulk factory read planner that can turn `PoolQuery`/typed protocol - queries into derived storage reads without executing them one at a time. +1. **Done.** `PoolDiscovery::find`/`find_many` turn `PoolQuery`s into derived + candidate reads resolved in one batched `read_storage_slots` call + (`PoolFactory::candidate_reads` + `assemble_pairs`). 2. Add a `BootstrapPlan` or `AdapterBootstrapPlanner` trait for protocol adapters to contribute code seeds, metadata requirements, storage programs, and fallback reasons. diff --git a/docs/writing-an-adapter.md b/docs/writing-an-adapter.md index 4f85d3b..1a1ef82 100644 --- a/docs/writing-an-adapter.md +++ b/docs/writing-an-adapter.md @@ -14,7 +14,7 @@ All the types below live in `evm_amm_state::adapters`. ## The `AmmAdapter` trait An adapter is one implementation of [`AmmAdapter`](../src/adapters/traits.rs). -It has **one required method** and six defaulted ones: +It has **one required method**; every other method (nine today) is defaulted: | Method | Required? | Default | | --- | --- | --- | @@ -22,7 +22,9 @@ It has **one required method** and six defaulted ones: | `protocols(&self) -> Vec` | no | `[self.protocol()]` | | `event_sources(&self, pool) -> Vec` | no | the pool's configured sources | | `route_log(&self, log, registry) -> Option` | no | generic address routing | +| `pool_factories(&self, config) -> Vec>` | no | `[]` (no factory discovery) | | `cold_start_planner(&self, pool, policy)` | no | `Err(UnsupportedReason::Protocol(..))` | +| `code_seeds(&self, pool)` | no | `Ok(vec![])` (no bytecode seeding) | | `decode_event(&self, pool, log, view)` | no | `AdapterEventResult::ignored()` | | `after_apply(&self, pool, event, diff)` | no | `RepairAction::None` | | `simulate_swap(&self, pool, cache, token_in, token_out, amount_in, config)` | no | `Err(SimError::Unsupported(..))` | diff --git a/examples/custom_adapter.rs b/examples/custom_adapter.rs index f7c35a5..159e586 100644 --- a/examples/custom_adapter.rs +++ b/examples/custom_adapter.rs @@ -23,8 +23,8 @@ //! 3. [`ProtocolMetadata::Custom(Arc)`] — an opaque, //! per-pool config blob you define, recovered inside the adapter with //! `downcast_ref`. -//! 4. The [`AmmAdapter`] trait — one *required* method (`protocol`); the other -//! six are defaulted, so a minimal adapter overrides only `protocol` + +//! 4. The [`AmmAdapter`] trait — one *required* method (`protocol`); the rest +//! are defaulted, so a minimal adapter overrides only `protocol` + //! `simulate_swap`. //! 5. [`AdapterRegistry::register_adapter`] + dispatch by //! [`AdapterRegistry::adapter`]`(pool.protocol())`. diff --git a/examples/v3_full_sync.rs b/examples/v3_full_sync.rs index 392ace6..7da7698 100644 --- a/examples/v3_full_sync.rs +++ b/examples/v3_full_sync.rs @@ -21,7 +21,8 @@ //! ±2-word active window (the planner-round shape, collapsed to one call). //! //! RPC economics (Alchemy): the full sync is ONE `eth_call` = 26 CU (20 via -//! `eth_callMany`) versus ~134k CU for the same slots as point reads. +//! `eth_callMany`) versus ~130k CU for the same slots as per-slot point reads +//! (7,674 `eth_getStorageAt` x 17 CU). //! //! ```text //! E2E_RPC_URL= cargo run --release --example v3_full_sync diff --git a/tests/bytecode_golden.rs b/tests/bytecode_golden.rs index a2390a9..e294e4b 100644 --- a/tests/bytecode_golden.rs +++ b/tests/bytecode_golden.rs @@ -7,7 +7,7 @@ //! pool's immutables must reproduce the exact hash. This is what catches a //! corrupted artifact or a wrong patch offset **offline**, with no RPC — the //! live `verified_bytecode_seed` example proves the same thing against a live -//! node, but only when `E2E_RPC_URL` is set. +//! node when `E2E_RPC_URL` is set; this test file itself needs no env at all. //! //! The V3 pools span tickSpacings 1 / 10 / 60 (fees 0.01% / 0.05% / 0.3%) with //! two distinct token pairs, so a wrong offset on `token0`, `token1`, `fee`, From ca724d6360bce152a226d19d1e37853fda9202b6 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Wed, 8 Jul 2026 09:50:32 +0100 Subject: [PATCH 06/14] =?UTF-8?q?docs(readme):=20storefront=20pass=20?= =?UTF-8?q?=E2=80=94=20badges,=20install,=20examples=20index,=20doctested?= =?UTF-8?q?=20quickstart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crates.io / docs.rs / CI / license / MSRV badges - Installation section: cargo add, feature trimming, MSRV 1.88, and the public-dependency re-export policy (evm_fork_cache / alloy_primitives at the root, lockstep releases) - 12-example index with a zero-setup 'start here' entry point - Slipstream quoting caveat surfaced beside the Solidly one (matches the support matrix); 'all protocol features on by default' corrected (pancake-v3/slipstream are uniswap-v3 aliases) - README code samples compile as doctests via a cfg(doctest) include (quickstart flipped ignore -> no_run; verified by cargo test --doc) Co-Authored-By: Claude Fable 5 --- README.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/lib.rs | 7 ++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e7bac92..f87beaf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # evm-amm-state +[![crates.io](https://img.shields.io/crates/v/evm-amm-state.svg)](https://crates.io/crates/evm-amm-state) +[![docs.rs](https://img.shields.io/docsrs/evm-amm-state)](https://docs.rs/evm-amm-state) +[![CI](https://github.com/KaiCode2/evm-amm-state/actions/workflows/ci.yml/badge.svg)](https://github.com/KaiCode2/evm-amm-state/actions/workflows/ci.yml) +[![license](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license) +[![MSRV](https://img.shields.io/badge/MSRV-1.88-informational)](https://github.com/KaiCode2/evm-amm-state/blob/main/Cargo.toml) + `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, and keeps them current **from chain log @@ -20,6 +26,30 @@ contracts. [`evm-fork-cache`]: https://github.com/KaiCode2/evm-fork-cache +## Installation + +```bash +cargo add evm-amm-state +``` + +All five protocol adapters are enabled by default; trim to what you use with +feature flags: + +```toml +[dependencies] +evm-amm-state = { version = "0.1", default-features = false, features = [ + "uniswap-v3", + "curve", +] } +``` + +Requires Rust **1.88+** (the declared MSRV, checked in CI). The two public +dependencies whose types appear in this crate's API are re-exported at the +crate root — import `evm_amm_state::evm_fork_cache` and +`evm_amm_state::alloy_primitives` instead of pinning them yourself, and the +versions always match. `evm-fork-cache` is a 0.x companion released in +lockstep: a breaking bump there is a breaking bump here. + ## The pipeline Each protocol is a single [`AmmAdapter`] implementation; the @@ -52,12 +82,19 @@ 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 +All protocol adapters are on by default; `pancake-v3` and `slipstream` are +thin aliases of `uniswap-v3` (one V3-family adapter serves all three). 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. +> **Slipstream quoting caveat.** Slipstream / Aerodrome CL ships as +> discovery + cold-start: its own quoter ABI differs (int24 tickSpacing), so +> discovered registrations leave `fee` unset and `simulate_swap` returns +> `MissingMetadata` until you supply a Uniswap-compatible quoter + fee — see +> the [support matrix](docs/protocol-support-matrix.md). + > **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 @@ -199,7 +236,7 @@ The V3 cold-start tick-scan radius is per-pool configurable via Register a pool, cold-start it into a forked cache, and simulate a swap entirely offline once warmed: -```rust,ignore +```rust,no_run use std::sync::Arc; use alloy_eips::{BlockId, BlockNumberOrTag}; @@ -388,6 +425,27 @@ the arbitrage examples above show exactly that). Standard view interfaces are declared locally with `alloy_sol_types::sol!`, so the crate builds from source with no generated bindings crate. +## Examples + +**Start here — zero setup, no RPC:** `cargo run --example custom_adapter` +(defines a novel AMM outside the crate, registers it, quotes both directions). +Everything else is env-gated and prints a skip message when unset: + +| Example | Shows | Needs | +| --- | --- | --- | +| [`custom_adapter`](examples/custom_adapter.rs) | third-party adapter, register → quote | — | +| [`adapter_pipeline`](examples/adapter_pipeline.rs) | register → cold-start → WS react → quote | `ETH_WS_URL` or `E2E_RPC_URL` | +| [`factory_discovery_live`](examples/factory_discovery_live.rs) | discovery → cold-start → reactive | `E2E_RPC_URL` | +| [`declarative_discovery`](examples/declarative_discovery.rs) | token-basket `PoolQuery` → `cold_start_many` | `E2E_RPC_URL` | +| [`token_basket_bench`](examples/token_basket_bench.rs) | batched vs per-pair discovery timing | `E2E_RPC_URL` | +| [`v3_full_sync`](examples/v3_full_sync.rs) | one-shot full-pool V3 sync + quote parity | `E2E_RPC_URL` | +| [`verified_bytecode_seed`](examples/verified_bytecode_seed.rs) | seeding + on-chain code-hash verification | `E2E_RPC_URL` | +| [`sync_latency`](examples/sync_latency.rs) | prior vs one-shot sync latency per protocol | `E2E_RPC_URL` (public fallback) | +| [`curve_cold_start_phases`](examples/curve_cold_start_phases.rs) | Curve discovery vs verify-only vs bundled | `E2E_RPC_URL` (public fallback) | +| [`trace_resync_latency`](examples/trace_resync_latency.rs) | event-time trace resync vs storage fallback | `E2E_RPC_URL` | +| [`arbitrage_cross_dex`](examples/arbitrage_cross_dex.rs) | offline cross-DEX round-trip pricing | `E2E_RPC_URL` (archive) | +| [`arbitrage_triangular`](examples/arbitrage_triangular.rs) | offline triangular cycle pricing | `E2E_RPC_URL` (archive) | + ## Testing ```bash diff --git a/src/lib.rs b/src/lib.rs index b5488dd..8937388 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,3 +53,10 @@ pub use evm_fork_cache; // Always compiled — the adapter layer has no heavy deps. pub mod adapters; + +/// Compiles the README's code samples as doctests so the quickstart cannot +/// drift from the real API. Exists only under `cfg(doctest)` — it is never +/// part of the built crate or the rendered docs. +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +pub struct ReadmeDoctests; From 07e90a871058677cfd4896520cab8cdb3e724faa Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Wed, 8 Jul 2026 09:53:59 +0100 Subject: [PATCH 07/14] test: pin packed-word boundaries + Solidly on the sync engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V3 apply_liquidity_delta and Balancer apply_cash_delta get exact-boundary unit tests: filling to u128::MAX / 2^112-1 is representable, one unit past is None (resync fallback) — never wrap/saturate/carry into a co-tenant field; oversize amounts are rejected up front. - Solidly V2 joins the AmmSyncEngine coverage (it was the only protocol with no engine/WS test): a Sync ingests as two exact reserve writes with zero resync work, proven by panicking trace/storage fetchers. Co-Authored-By: Claude Fable 5 --- Cargo.toml | 2 +- src/adapters/balancer_v2.rs | 35 +++++++++++++++++ src/adapters/uniswap_v3.rs | 25 ++++++++++++ tests/adapter_sync_manager.rs | 72 ++++++++++++++++++++++++++++++++++- 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6959d5b..ccbe760 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -172,7 +172,7 @@ required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2", "solidly-v2", "c [[test]] name = "adapter_sync_manager" -required-features = ["curve", "balancer-v2"] +required-features = ["curve", "balancer-v2", "solidly-v2"] [[test]] name = "cold_start_adoption" diff --git a/src/adapters/balancer_v2.rs b/src/adapters/balancer_v2.rs index 2404ffb..cb22164 100644 --- a/src/adapters/balancer_v2.rs +++ b/src/adapters/balancer_v2.rs @@ -883,6 +883,41 @@ mod tests { assert!(apply_cash_delta(full, false, true, U256::from(1_u64)).is_none()); } + // Pin the 112-bit field contract at its exact boundaries: checked + // (`None` -> the reactive path falls back to a resync), and an in-range + // write never disturbs the co-tenant field of a TWO_TOKEN shared slot. + #[test] + fn cash_delta_exact_112_bit_boundary_behavior() { + let max_cash = cash_mask(); + // Filling to exactly 2^112 - 1 is representable... + let almost = set_cash_field(U256::ZERO, false, max_cash - U256::from(3_u64)); + let full = apply_cash_delta(almost, false, true, U256::from(3_u64)).unwrap(); + assert_eq!(cash_field(full, false), max_cash); + // ...one more unit is None — never a carry into the neighbouring bits. + assert!(apply_cash_delta(full, false, true, U256::from(1_u64)).is_none()); + // Same at the high field; the low co-tenant cash must stay untouched. + let low_cotenant = U256::from(77_u64); + let both = set_cash_field( + set_cash_field(U256::ZERO, false, low_cotenant), + true, + max_cash - U256::from(1_u64), + ); + let bumped = apply_cash_delta(both, true, true, U256::from(1_u64)).unwrap(); + assert_eq!(cash_field(bumped, true), max_cash); + assert_eq!(cash_field(bumped, false), low_cotenant); + assert!(apply_cash_delta(bumped, true, true, U256::from(1_u64)).is_none()); + // Subtracting below zero rejects. + let two = set_cash_field(U256::ZERO, false, U256::from(2_u64)); + assert!(apply_cash_delta(two, false, false, U256::from(3_u64)).is_none()); + // A full-width U256 amount cannot smuggle past the 112-bit check, while + // the exact field maximum as an amount is accepted. + assert!( + apply_cash_delta(U256::ZERO, false, true, U256::from(1_u64) << CASH_BITS).is_none() + ); + let filled = apply_cash_delta(U256::ZERO, false, true, max_cash).unwrap(); + assert_eq!(cash_field(filled, false), max_cash); + } + #[test] fn probe_locates_two_token_shared_slot() { let vault = addr(0xba); diff --git a/src/adapters/uniswap_v3.rs b/src/adapters/uniswap_v3.rs index c8573d6..4e776eb 100644 --- a/src/adapters/uniswap_v3.rs +++ b/src/adapters/uniswap_v3.rs @@ -1019,6 +1019,31 @@ mod tests { assert!(apply_liquidity_delta(pack_gross_net(3, 3), 4, false, true).is_none()); } + // Pin the contract at the exact 128-bit boundaries: checked arithmetic + // (`None` -> the caller resyncs the tick) — never a wrap or saturation + // silently packed into a wrong word. + #[test] + fn liquidity_delta_boundary_values_reject_not_wrap() { + // Filling gross to exactly u128::MAX is representable... + let (w, was, now) = + apply_liquidity_delta(pack_gross_net(u128::MAX - 4, 0), 4, true, true).unwrap(); + assert_eq!(gross(w), u128::MAX); + assert!(was && now); + // ...one more unit is None, not a wrap to zero. + assert!(apply_liquidity_delta(pack_gross_net(u128::MAX, 0), 1, true, true).is_none()); + // Net overflow at i128::MAX (mint at the lower tick adds to net). + assert!(apply_liquidity_delta(pack_gross_net(0, i128::MAX), 1, true, true).is_none()); + // Net underflow at i128::MIN (mint at the upper tick subtracts). + assert!(apply_liquidity_delta(pack_gross_net(0, i128::MIN), 1, true, false).is_none()); + // An amount above i128::MAX cannot be a valid net move: rejected up front. + assert!(apply_liquidity_delta(pack_gross_net(0, 0), 1u128 << 127, true, true).is_none()); + // The largest representable amount round-trips exactly. + let amount = i128::MAX as u128; + let (w, _, _) = apply_liquidity_delta(pack_gross_net(0, 0), amount, true, true).unwrap(); + assert_eq!(gross(w), amount); + assert_eq!(net(w), i128::MAX); + } + #[test] fn bit_position_matches_uniswap_position_low_byte() { // spacing 1: compressed == tick; bit = tick mod 256 (floor for negatives). diff --git a/tests/adapter_sync_manager.rs b/tests/adapter_sync_manager.rs index 1a4a505..58fd156 100644 --- a/tests/adapter_sync_manager.rs +++ b/tests/adapter_sync_manager.rs @@ -11,7 +11,7 @@ use anyhow::Result; use evm_amm_state::adapters::{ AdapterRegistry, AmmAdapter, AmmSyncEngine, BalancerV2Adapter, BalancerV2Metadata, CurveAdapter, CurveMetadata, CurveVariant, PoolKey, PoolRegistration, PoolStatus, - ProtocolMetadata, + ProtocolMetadata, SolidlyStorageLayout, SolidlyV2Adapter, SolidlyV2Metadata, }; use evm_fork_cache::cache::{ BlockStateAccountDiff, BlockStateDiff, BlockStateStorageDiff, EvmCache, @@ -510,3 +510,73 @@ async fn sync_engine_eviction_purges_exclusive_state_only() -> Result<()> { ); Ok(()) } + +fn solidly_sync_topic() -> B256 { + keccak256(b"Sync(uint256,uint256)") +} + +fn solidly_registry(pool: Address, r0_slot: U256, r1_slot: U256) -> Result { + let layout = + SolidlyStorageLayout::new(r0_slot, r1_slot, U256::from(12_u64), U256::from(13_u64)); + let adapter = Arc::new(SolidlyV2Adapter::default()); + let mut registration = PoolRegistration::new(PoolKey::SolidlyV2(pool)) + .with_state_address(pool) + .with_status(PoolStatus::Ready) + .with_metadata(ProtocolMetadata::SolidlyV2( + SolidlyV2Metadata::default() + .with_stable(false) + .with_storage_layout(layout), + )); + let sources = adapter.event_sources(®istration); + registration = registration.with_event_sources(sources); + + let mut registry = AdapterRegistry::new(); + registry.register_adapter(adapter)?; + registry.register_pool(registration)?; + Ok(registry) +} + +// Solidly is the exact-write protocol on the engine: a `Sync` event carries the +// absolute reserves, so `AmmSyncEngine` must apply both slot writes directly +// from the payload with ZERO resync work — no block trace, no storage fetch — +// and leave the pool `Ready`. (The panicking fetchers make any fallback loud.) +#[tokio::test] +async fn sync_engine_applies_solidly_sync_exactly_with_no_resync() -> Result<()> { + let pool = Address::repeat_byte(0x51); + let (r0_slot, r1_slot) = (U256::from(10_u64), U256::from(11_u64)); + let mut cache = setup_cache().await?; + cache.set_block_state_diff_fetcher(Arc::new(|block| { + panic!("exact event-sourcing must not request a block trace: {block:?}") + })); + cache.set_storage_batch_fetcher(Arc::new(|requests, _block| { + panic!("exact event-sourcing must not fetch storage: {requests:?}") + })); + + let registry = solidly_registry(pool, r0_slot, r1_slot)?; + let mut engine = AmmSyncEngine::new(registry)?; + + let (reserve0, reserve1) = (U256::from(123_456_u64), U256::from(789_012_u64)); + let log = rpc_log( + pool, + vec![solidly_sync_topic()], + abi_words([reserve0, reserve1]), + 90, + ); + let report = engine.ingest_batch(&mut cache, batch(log, 90))?; + + assert_eq!(report.reactive.applied.len(), 1); + assert_eq!(report.resync_state_updates, 0, "Sync is exact: no resync"); + assert_eq!(report.resync_failures, 0); + assert!(report.degraded_pools.is_empty()); + assert_eq!(cache.cached_storage_value(pool, r0_slot), Some(reserve0)); + assert_eq!(cache.cached_storage_value(pool, r1_slot), Some(reserve1)); + assert_eq!( + engine + .registry() + .pool(&PoolKey::SolidlyV2(pool)) + .expect("registered pool") + .status, + PoolStatus::Ready + ); + Ok(()) +} From 867aba7cdadf5c09ee08fc304f02e2142a7bdd28 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Wed, 8 Jul 2026 10:01:49 +0100 Subject: [PATCH 08/14] bench: fully-offline reactive_apply harness + measured event-sourcing rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New benches/reactive_apply.rs (criterion, no env, mock-backed EvmCache with pre-warmed packed words — the adapter_reactive.rs fixtures) measures the event-sourced hot paths end-to-end through AdapterDriver::apply_log: v2_sync ~283 ns (agrees with the RPC-warmed ~249 ns) balancer_swap_two_token ~432 ns balancer_swap_general ~519 ns v3_mint_warm ~1.42 us v3_burn_warm ~1.35 us Headroom-sized initial state keeps millions of iterations on the warm path (burns never empty the tick, cash never leaves its 112-bit field), and pre-flight asserts pin exact writes before measuring. benchmarks.md's reactive table now carries all five paths instead of scoping everything to the single V2 row, and documents the offline reproduce command. Co-Authored-By: Claude Fable 5 --- Cargo.toml | 9 + benches/reactive_apply.rs | 390 ++++++++++++++++++++++++++++++++++++++ docs/benchmarks.md | 28 ++- 3 files changed, 421 insertions(+), 6 deletions(-) create mode 100644 benches/reactive_apply.rs diff --git a/Cargo.toml b/Cargo.toml index ccbe760..b81d96b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -231,6 +231,15 @@ name = "swap_sim" harness = false required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2", "solidly-v2", "curve"] +# Fully-OFFLINE reactive-apply micro-benchmarks (mock-backed cache, pre-warmed +# packed words): the event-sourced hot paths — V2 Sync, V3 Mint/Burn onto warm +# ticks, Balancer Swap onto probed cash fields. No RPC, no env; runs anywhere, +# including CI, for regression tracking. +[[bench]] +name = "reactive_apply" +harness = false +required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2"] + # One-shot V3 full-pool sync via a generated eth_call storage program: # the whole tick range + observation ring in a single call, quote-parity # checked against the classic windowed cold start (env-gated). diff --git a/benches/reactive_apply.rs b/benches/reactive_apply.rs new file mode 100644 index 0000000..9bca1d6 --- /dev/null +++ b/benches/reactive_apply.rs @@ -0,0 +1,390 @@ +//! Fully-offline reactive-apply micro-benchmarks: decode + route + apply one +//! event through [`AdapterDriver`] for each **event-sourced** (exact-write, +//! no-RPC) hot path — Uniswap V2 `Sync`, Uniswap V3 `Mint`/`Burn` onto warm +//! ticks, and Balancer V2 vault `Swap`s onto probed cash fields. +//! +//! Unlike `swap_sim.rs` (which cold-starts real pools over RPC before its +//! micro-benches), this harness needs **no network and no env vars**: the +//! cache is a mock-transport [`EvmCache`] and the slots each apply path reads +//! are pre-warmed with the exact packed words the adapters expect (the same +//! fixtures as `tests/adapter_reactive.rs`). Run it anywhere: +//! +//! ```text +//! cargo bench --bench reactive_apply +//! ``` +//! +//! Each measured iteration is one `AdapterDriver::apply_log`: topic routing, +//! ABI decode, packed-word arithmetic, and the cache write(s). Warm-path +//! invariants (no resync scheduled) are asserted once before measuring, and +//! initial values leave enough headroom that millions of repeated applies stay +//! on the warm path (a V3 burn never empties its tick, Balancer cash never +//! under/overflows its 112-bit field). + +use std::sync::Arc; + +use alloy_network::AnyNetwork; +use alloy_primitives::{Address, B256, Bytes, Log, U256, keccak256}; +use alloy_provider::RootProvider; +use alloy_rpc_client::RpcClient; +use alloy_transport::mock::Asserter; +use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use evm_amm_state::adapters::storage::{ + V2_RESERVES_SLOT, V3StorageLayout, v3_tick_info_storage_keys_with_base, +}; +use evm_amm_state::adapters::{ + AdapterDriver, AdapterRegistry, AmmAdapter, BalancerTokenBalance, BalancerV2Adapter, + BalancerV2Metadata, ConcentratedLiquidityAdapter, PoolKey, PoolRegistration, ProtocolMetadata, + UniswapV2Adapter, UniswapV2Metadata, V3Metadata, +}; +use evm_fork_cache::StateUpdate; +use evm_fork_cache::cache::EvmCache; +use tokio::runtime::Runtime as Rt; + +// --- offline cache ----------------------------------------------------------- + +fn mock_cache(rt: &Rt) -> EvmCache { + rt.block_on(async { + let asserter = Asserter::new(); + let client = RpcClient::mocked(asserter); + let provider = RootProvider::::new(client); + EvmCache::new(Arc::new(provider)).await + }) +} + +// --- log fixtures (mirroring tests/adapter_reactive.rs) ----------------------- + +fn word(value: U256) -> Vec { + value.to_be_bytes::<32>().to_vec() +} + +fn abi_words(values: impl IntoIterator) -> Vec { + values.into_iter().flat_map(word).collect() +} + +fn address_word(address: Address) -> Vec { + let mut bytes = [0u8; 32]; + bytes[12..].copy_from_slice(address.as_slice()); + bytes.to_vec() +} + +fn topic_address(address: Address) -> B256 { + let mut bytes = [0u8; 32]; + bytes[12..].copy_from_slice(address.as_slice()); + B256::from(bytes) +} + +fn topic_i24(value: i32) -> B256 { + let mut bytes = if value < 0 { [0xff; 32] } else { [0u8; 32] }; + let raw = value.to_be_bytes(); + bytes[29..32].copy_from_slice(&raw[1..4]); + B256::from(bytes) +} + +/// 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) +} + +/// Pack a V3 `slot0`: `sqrtPriceX96` (160 bits) + current tick (24 bits). +fn v3_slot0_word(sqrt_price: U256, tick: i32) -> U256 { + sqrt_price | (U256::from((tick as u32) & 0x00FF_FFFF) << 160) +} + +/// A Balancer poolId: 20 pool-address bytes, then the 2-byte specialization. +fn balancer_pool_id(specialization: u16, seed: u8) -> B256 { + let mut bytes = [seed; 32]; + bytes[20..22].copy_from_slice(&specialization.to_be_bytes()); + B256::from(bytes) +} + +// --- benches ------------------------------------------------------------------ + +/// Uniswap V2 `Sync`: one exact masked write of the packed reserves word. +fn bench_v2_sync(c: &mut Criterion, rt: &Rt) { + let pair = Address::repeat_byte(0x21); + let adapter = UniswapV2Adapter::default(); + let mut cache = mock_cache(rt); + // The masked write needs the slot warm; any packed word will do. + cache.apply_updates(&[StateUpdate::slot( + pair, + V2_RESERVES_SLOT, + (U256::from(1_u64) << 224) | (U256::from(1_u64) << 112) | U256::from(1_u64), + )]); + + let mut reg = PoolRegistration::new(PoolKey::UniswapV2(pair)) + .with_state_address(pair) + .with_metadata(ProtocolMetadata::UniswapV2( + UniswapV2Metadata::default() + .with_token0(Address::repeat_byte(0x01)) + .with_token1(Address::repeat_byte(0x02)) + .with_fee_bps(30), + )); + reg.event_sources = adapter.event_sources(®); + let mut registry = AdapterRegistry::new(); + registry + .register_adapter(Arc::new(adapter)) + .expect("adapter"); + registry.register_pool(reg).expect("pool"); + let driver = AdapterDriver::new(registry); + + let reserve0 = U256::from(40_000_000_000_000_u64); + let reserve1 = U256::from(20_000_000_000_000_000_000_u128); + let log = Log::new_unchecked( + pair, + vec![keccak256("Sync(uint112,uint112)")], + Bytes::from(abi_words([reserve0, reserve1])), + ); + + // Pre-flight: the write must land exactly before we measure anything. + driver + .apply_log(&mut cache, &log) + .expect("apply ok") + .expect("Sync must route + apply"); + let packed = cache + .cached_storage_value(pair, V2_RESERVES_SLOT) + .expect("warm"); + assert_eq!( + packed & ((U256::from(1_u64) << 112) - U256::from(1_u64)), + reserve0 + ); + + c.bench_function("reactive_apply/v2_sync", |b| { + b.iter(|| { + let r = driver.apply_log(&mut cache, &log).expect("apply ok"); + black_box(r) + }) + }); +} + +/// Uniswap V3 `Mint`/`Burn` onto warm, already-initialized boundary ticks: the +/// event-sourced path writes the packed gross/net words and the in-range +/// global liquidity directly — no resync. +fn bench_v3_liquidity(c: &mut Criterion, rt: &Rt) { + let pool = Address::repeat_byte(0x42); + let layout = V3StorageLayout::uniswap(60); + let (tick_lower, tick_upper) = (60, 180); // current tick 120 in [60, 180) + 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 adapter = ConcentratedLiquidityAdapter::default(); + let mut cache = mock_cache(rt); + // Headroom so millions of repeated applies stay on the warm path: a burn + // never empties its tick, a mint never overflows gross or global liquidity. + let headroom = u128::MAX / 2; + cache.apply_updates(&[ + StateUpdate::slot( + pool, + layout.slot0_slot, + v3_slot0_word(U256::from(1_u64), 120), + ), + StateUpdate::slot(pool, layout.liquidity_slot, U256::from(headroom)), + StateUpdate::slot(pool, lower_key, packed_tick_word0(headroom, 40)), + StateUpdate::slot(pool, upper_key, packed_tick_word0(headroom, -40)), + ]); + + let mut reg = PoolRegistration::new(PoolKey::UniswapV3(pool)) + .with_state_address(pool) + .with_metadata(ProtocolMetadata::UniswapV3( + V3Metadata::default().with_storage_layout(layout), + )); + reg.event_sources = adapter.event_sources(®); + let mut registry = AdapterRegistry::new(); + registry + .register_adapter(Arc::new(adapter)) + .expect("adapter"); + registry.register_pool(reg).expect("pool"); + let driver = AdapterDriver::new(registry); + + let mint_log = Log::new_unchecked( + pool, + vec![ + keccak256("Mint(address,address,int24,int24,uint128,uint256,uint256)"), + topic_address(Address::repeat_byte(0x04)), + topic_i24(tick_lower), + topic_i24(tick_upper), + ], + Bytes::from({ + let mut data = address_word(Address::repeat_byte(0x03)); + data.extend(abi_words([ + U256::from(7_u64), // amount + U256::from(8_u64), + U256::from(9_u64), + ])); + data + }), + ); + let burn_log = Log::new_unchecked( + pool, + vec![ + keccak256("Burn(address,int24,int24,uint128,uint256,uint256)"), + topic_address(Address::repeat_byte(0x04)), + topic_i24(tick_lower), + topic_i24(tick_upper), + ], + Bytes::from(abi_words([ + U256::from(7_u64), // amount + U256::from(8_u64), + U256::from(9_u64), + ])), + ); + + // Pre-flight both directions: gross must move by ±7 (warm event-sourcing), + // never a resync-only report. + driver + .apply_log(&mut cache, &mint_log) + .expect("apply ok") + .expect("Mint must route + apply"); + let after_mint = cache.cached_storage_value(pool, lower_key).expect("warm"); + assert_eq!( + after_mint & ((U256::from(1_u64) << 128) - U256::from(1_u64)), + U256::from(headroom + 7) + ); + driver + .apply_log(&mut cache, &burn_log) + .expect("apply ok") + .expect("Burn must route + apply"); + let after_burn = cache.cached_storage_value(pool, lower_key).expect("warm"); + assert_eq!( + after_burn & ((U256::from(1_u64) << 128) - U256::from(1_u64)), + U256::from(headroom) + ); + + c.bench_function("reactive_apply/v3_mint_warm", |b| { + b.iter(|| { + let r = driver.apply_log(&mut cache, &mint_log).expect("apply ok"); + black_box(r) + }) + }); + c.bench_function("reactive_apply/v3_burn_warm", |b| { + b.iter(|| { + let r = driver.apply_log(&mut cache, &burn_log).expect("apply ok"); + black_box(r) + }) + }); +} + +/// Balancer V2 vault `Swap` with probed cash fields: exact 112-bit field +/// writes — the TWO_TOKEN shared slot gets one combined write, a GENERAL pool +/// two per-token writes. +fn bench_balancer_swap(c: &mut Criterion, rt: &Rt) { + let vault = Address::repeat_byte(0x52); + let token_in = Address::repeat_byte(0x01); + let token_out = Address::repeat_byte(0x02); + + // TWO_TOKEN: both cash fields share one slot (in = low, out = high). + let two_token_id = balancer_pool_id(2, 0xc3); + let shared_slot = U256::from(0x77_u64); + // GENERAL: one slot per token (both low fields). + let general_id = balancer_pool_id(0, 0xd4); + let (slot_in, slot_out) = (U256::from(0x11_u64), U256::from(0x22_u64)); + + let adapter = Arc::new(BalancerV2Adapter::default()); + let mut cache = mock_cache(rt); + // cash_out starts near the top of the 112-bit field, cash_in near the + // bottom: millions of (+30 in / -20 out) applies stay in range. + let cash_in0 = U256::from(1_u64) << 80; + let cash_out0 = U256::from(1_u64) << 111; + cache.apply_updates(&[ + StateUpdate::slot( + vault, + shared_slot, + (U256::from(0xABCD_u64) << 224) | (cash_out0 << 112) | cash_in0, + ), + StateUpdate::slot(vault, slot_in, cash_in0), + StateUpdate::slot(vault, slot_out, cash_out0), + ]); + + let mut registry = AdapterRegistry::new(); + registry.register_adapter(adapter.clone()).expect("adapter"); + for (pool_id, token_cash) in [ + ( + two_token_id, + vec![ + BalancerTokenBalance::new(token_in, shared_slot, false), + BalancerTokenBalance::new(token_out, shared_slot, true), + ], + ), + ( + general_id, + vec![ + BalancerTokenBalance::new(token_in, slot_in, false), + BalancerTokenBalance::new(token_out, slot_out, false), + ], + ), + ] { + let mut reg = PoolRegistration::new(PoolKey::BalancerV2(pool_id)) + .with_state_address(vault) + .with_metadata(ProtocolMetadata::BalancerV2( + BalancerV2Metadata::default() + .with_vault(vault) + .with_tokens([token_in, token_out]) + .with_token_cash(token_cash), + )); + reg.event_sources = adapter.event_sources(®); + registry.register_pool(reg).expect("pool"); + } + let driver = AdapterDriver::new(registry); + + let swap_log = |pool_id: B256| { + Log::new_unchecked( + vault, + vec![ + keccak256("Swap(bytes32,address,address,uint256,uint256)"), + pool_id, + topic_address(token_in), + topic_address(token_out), + ], + Bytes::from(abi_words([U256::from(30_u64), U256::from(20_u64)])), + ) + }; + let two_token_log = swap_log(two_token_id); + let general_log = swap_log(general_id); + + // Pre-flight: cash fields must move exactly (event-sourced, not resync). + driver + .apply_log(&mut cache, &two_token_log) + .expect("apply ok") + .expect("Swap must route + apply"); + let shared = cache + .cached_storage_value(vault, shared_slot) + .expect("warm"); + let mask112 = (U256::from(1_u64) << 112) - U256::from(1_u64); + assert_eq!(shared & mask112, cash_in0 + U256::from(30_u64)); + assert_eq!((shared >> 112) & mask112, cash_out0 - U256::from(20_u64)); + driver + .apply_log(&mut cache, &general_log) + .expect("apply ok") + .expect("Swap must route + apply"); + assert_eq!( + cache.cached_storage_value(vault, slot_in).expect("warm") & mask112, + cash_in0 + U256::from(30_u64) + ); + + c.bench_function("reactive_apply/balancer_swap_two_token", |b| { + b.iter(|| { + let r = driver + .apply_log(&mut cache, &two_token_log) + .expect("apply ok"); + black_box(r) + }) + }); + c.bench_function("reactive_apply/balancer_swap_general", |b| { + b.iter(|| { + let r = driver + .apply_log(&mut cache, &general_log) + .expect("apply ok"); + black_box(r) + }) + }); +} + +fn benches(c: &mut Criterion) { + let rt = Rt::new().expect("tokio runtime"); + bench_v2_sync(c, &rt); + bench_v3_liquidity(c, &rt); + bench_balancer_swap(c, &rt); +} + +criterion_group!(reactive_apply, benches); +criterion_main!(reactive_apply); diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 7f74559..f358e00 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -23,9 +23,13 @@ qualitatively, to the other ways people price AMM swaps. - **Reproduce:** ```bash E2E_RPC_URL= cargo bench --bench swap_sim + cargo bench --bench reactive_apply # fully offline — no env needed ``` - (Env-gated — a no-op without the URL. Solidly uses Base: `E2E_BASE_RPC_URL`, - or an Alchemy `E2E_RPC_URL` with `eth-mainnet`→`base-mainnet`.) + (`swap_sim` is env-gated — a no-op without the URL; Solidly uses Base: + `E2E_BASE_RPC_URL`, or an Alchemy `E2E_RPC_URL` with + `eth-mainnet`→`base-mainnet`. `reactive_apply` measures the event-sourced + apply paths against a mock-backed cache with pre-warmed packed words, so it + runs anywhere, including CI.) ## Results @@ -65,14 +69,26 @@ qualitatively, to the other ways people price AMM swaps. ### Reactive event apply & cold-start +Every event-sourced apply below is one `AdapterDriver::apply_log`: topic +routing, ABI decode, packed-word arithmetic, and the exact cache write(s) — +no RPC. The V2 row is measured in both harnesses (RPC-warmed `swap_sim` and +offline `reactive_apply`) and agrees; the other apply rows come from +[`benches/reactive_apply.rs`](../benches/reactive_apply.rs) (offline, July +2026, same host). + | Operation | Time | Notes | | --- | ---: | --- | -| Apply one `Sync` (Uniswap V2 exact write) | **~249 ns** | decode + route + masked slot write; ~4M events/sec | +| Apply one Uniswap V2 `Sync` (exact write) | **~250–285 ns** | one masked reserves-word write; ~3.5–4M events/sec | +| Apply one Balancer V2 `Swap` (TWO_TOKEN, event-sourced) | **~430 ns** | both 112-bit cash fields, one shared-slot write | +| Apply one Balancer V2 `Swap` (GENERAL, event-sourced) | **~520 ns** | two per-token cash-field writes | +| Apply one Uniswap V3 `Mint` (warm ticks, event-sourced) | **~1.4 µs** | packed gross/net on both boundary ticks + in-range global liquidity | +| Apply one Uniswap V3 `Burn` (warm ticks, event-sourced) | **~1.4 µs** | the same three writes, negated | | Cold-start one pool (Uniswap V3) | **~1.06 s** | **one-time, network-bound** — archive-node latency dominates; amortized over every later offline quote | -The reactive exact-write path is effectively free relative to a quote. Cold-start -is a one-time setup cost gated by RPC latency, not a steady-state cost — the -crate's design pays it once and then quotes offline forever. +The reactive event-sourced paths are effectively free relative to a quote +(hundreds of nanoseconds to ~1.4 µs vs 8–85 µs). Cold-start is a one-time +setup cost gated by RPC latency, not a steady-state cost — the crate's design +pays it once and then quotes offline forever. ### One-shot sync latency — network-bound state loading From a8a5973848394532c038de4ca80b3f9191180e68 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Wed, 8 Jul 2026 10:02:37 +0100 Subject: [PATCH 09/14] examples: warn loudly when falling back to the public endpoint sync_latency and curve_cold_start_phases run multi-iteration cold-start loops; on the publicnode fallback a rate limit aborts them mid-run with a bare transport error. Say so up front and point at E2E_RPC_URL. Co-Authored-By: Claude Fable 5 --- examples/curve_cold_start_phases.rs | 9 ++++++++- examples/sync_latency.rs | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/examples/curve_cold_start_phases.rs b/examples/curve_cold_start_phases.rs index 7dacf7d..f96ee49 100644 --- a/examples/curve_cold_start_phases.rs +++ b/examples/curve_cold_start_phases.rs @@ -87,7 +87,14 @@ impl PhaseStats { #[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 url = std::env::var("E2E_RPC_URL").unwrap_or_else(|_| { + eprintln!( + "E2E_RPC_URL unset - falling back to {DEFAULT_RPC_URL}. Public endpoints \ + rate-limit multi-iteration runs and can abort this example mid-way; set \ + E2E_RPC_URL to a paid/archive endpoint for reliable numbers." + ); + DEFAULT_RPC_URL.to_string() + }); let iterations = std::env::var("CURVE_PHASES_ITERS") .ok() .and_then(|v| v.parse().ok()) diff --git a/examples/sync_latency.rs b/examples/sync_latency.rs index 31f978d..2d25261 100644 --- a/examples/sync_latency.rs +++ b/examples/sync_latency.rs @@ -82,7 +82,14 @@ impl SyncStats { #[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 url = std::env::var("E2E_RPC_URL").unwrap_or_else(|_| { + eprintln!( + "E2E_RPC_URL unset - falling back to {DEFAULT_RPC_URL}. Public endpoints \ + rate-limit multi-iteration runs and can abort this example mid-way; set \ + E2E_RPC_URL to a paid/archive endpoint for reliable numbers." + ); + DEFAULT_RPC_URL.to_string() + }); let iterations = std::env::var("SYNC_BENCH_ITERS") .ok() .and_then(|value| value.parse().ok()) From f088a06ebf04b2cc25efa6f470c84a5c6d8e9591 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Wed, 8 Jul 2026 10:03:30 +0100 Subject: [PATCH 10/14] chore: cargo-audit policy with a documented lockfile-only ignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RUSTSEC-2025-0055 (tracing-subscriber 0.2.25) resolves into Cargo.lock via revm-precompile's optional arkworks bn254 backend, which no feature of this crate ever enables — cargo tree finds it in no build graph, even with --target all --all-features. Ignore it with the justification checked in, so cargo audit is green and gateable. Co-Authored-By: Claude Fable 5 --- .cargo/audit.toml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .cargo/audit.toml diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 0000000..2fffa44 --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,18 @@ +# cargo-audit policy for evm-amm-state. +# +# Run `cargo audit` from the crate root; this ignore-list documents advisories +# that are present in Cargo.lock but provably outside every build graph. + +[advisories] +ignore = [ + # tracing-subscriber 0.2.25 (ANSI-escape log poisoning) enters Cargo.lock + # only through revm-precompile's OPTIONAL arkworks bn254 backend + # (revm-precompile -> ark-bn254 -> ark-relations -> tracing-subscriber). + # No feature of this crate (or of its enabled dependency features) turns + # that backend on: `cargo tree -i tracing-subscriber@0.2.25 --target all + # --all-features` resolves to nothing, so the crate is never compiled, + # linked, or shipped — the advisory is lockfile-resolution noise. Remove + # this entry when upstream revm-precompile drops or upgrades the arkworks + # backend. + "RUSTSEC-2025-0055", +] From 28572eeb0ba23bb8db8ccdbcd73c466ef07aee95 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Wed, 8 Jul 2026 10:05:22 +0100 Subject: [PATCH 11/14] docs: RELEASING/ROADMAP reflect the shipped release candidate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RELEASING: the live suites are now a required step covering all eight gated files (was 'optional' naming three), the package is 56 files / seven guides, the tag section documents the re-point procedure the v0.1.0 prep needed, and the post-publish section drops the CI-auth cleanup that already happened in favor of docs.rs badge checks and seeding the live workflow. ROADMAP: factory discovery shipped in 0.1.0 (the plan still promised it for 0.2.0 with pre-implementation API names); the 0.1.x/0.2.0 section now lists the real candidates — PR #32 access-list warming, Balancer log-scan + Curve MetaRegistry discovery, PoolBalanceManaged subscription, Algebra forks, Bootstrapper, incremental interests. Co-Authored-By: Claude Fable 5 --- RELEASING.md | 69 ++++++++++++++++++++++++++++++++++---------------- ROADMAP.md | 71 ++++++++++++++++++++++++++++------------------------ 2 files changed, 85 insertions(+), 55 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 53e599b..d7efc6e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -45,17 +45,27 @@ cargo tree -e normal --all-features | grep -E '(amms|amm-math|rayon) v[0-9]' || cargo +1.88 check --all-features ``` -Last run 2026-07-05 against the published `evm-fork-cache` 0.2.1: **all green.** +Last full run 2026-07-08 on the v0.1.0 release-polish branch: **all green.** -Optional but recommended — the env-gated network tests against an archive node -(`E2E_RPC_URL` lives in `.env`, gitignored; never commit or echo it): +**Required before a release** — the env-gated live suites are the only +on-chain ground truth (quote parity per protocol, factory base-slot/CREATE2 +constants, one-shot V3 sync parity, and the per-transaction `stateDiff` +write-parity suites for the event-sourced paths). Run the whole ignored set +against an archive node (`E2E_RPC_URL` lives in `.env`, gitignored; never +commit or echo it): ```bash set -a; . ./.env; set +a -cargo test --test adapter_swap_sim_rpc -- --ignored # RPC parity (mainnet + Base) -cargo test --test reactive_ws_e2e --test reactive_curve_ws_e2e -- --ignored --nocapture # live WS soak +cargo test --all-features -- --ignored --nocapture ``` +That covers all eight live files: `adapter_swap_sim_rpc` (mainnet + Base), +`v3_liquidity_rpc` + `balancer_liquidity_rpc` (per-tx write parity), +`v3_full_sync_rpc`, `discovery_cl_rpc`, `discovery_solidly_rpc`, and the +`reactive_ws_e2e` / `reactive_curve_ws_e2e` WS soaks. The same set runs +weekly in CI via `.github/workflows/live.yml` (needs the `E2E_RPC_URL` +repo secret). + ## 2. Package hygiene - Confirm `Cargo.toml` metadata is release-ready: `version`, `license` @@ -63,19 +73,20 @@ cargo test --test reactive_ws_e2e --test reactive_curve_ws_e2e -- --ignored --no `description`, `repository`, `documentation`, `readme`, `keywords`, `categories`, `rust-version` (MSRV `1.88`). - The `[package].exclude` list drops the test suite, CI config, maintainer docs - (ROADMAP/RELEASING), and superseded design specs. **Keep `exclude` inside the + (ROADMAP/RELEASING), and superseded design specs — the seven user-facing + `docs/` guides, all examples, both benches, and `.cargo/audit.toml` ship. **Keep `exclude` inside the `[package]` table** — writing it after a `[package.metadata.*]` header silently reparents it under that sub-table and Cargo ships everything (this regressed once; fixed in `9036873`). - Inspect what would ship: ```bash - cargo package --list # ~50 files: src, examples, benches, the five - # docs/ guides, README, licenses, changelog + cargo package --list # ~56 files: src, examples, both benches, the + # seven docs/ guides, README, licenses, changelog cargo publish --dry-run ``` - Last run 2026-07-05: **50 files, 768.6 KiB (207.6 KiB compressed), verifies - clean.** (`cargo publish` prints `ignoring test …` for the excluded `[[test]]` - targets — expected and harmless.) + Last run 2026-07-08: **56 files, verifies clean.** (`cargo publish` prints + `ignoring test …` for the excluded `[[test]]` targets — expected and + harmless.) ## 3. Version & changelog @@ -95,15 +106,28 @@ git push origin v0.1.0 cargo publish ``` -## 5. Post-publish cleanup +**If commits land on `main` after the tag exists** (this happened during +v0.1.0 prep: six PRs merged after the tag was cut), the tag must be re-pointed +at the final commit *before* `cargo publish` — a stale tag silently publishes +old code's provenance (`.cargo_vcs_info.json`, the GitHub release link, and +the changelog's `[x.y.z]` anchor all disagree with the crate contents): + +```bash +git push origin :refs/tags/v0.1.0 # delete the remote tag +git tag -fa v0.1.0 -m "evm-amm-state 0.1.0" +git push origin v0.1.0 +``` + +## 5. Post-publish checks -- Remove the three `Authenticate git for private companion crates` steps (in the - `check`, `isolation`, and `msrv` jobs) and the `CARGO_NET_GIT_FETCH_WITH_CLI` - env from [`.github/workflows/ci.yml`](.github/workflows/ci.yml), and delete the - `PRIVATE_REPO_TOKEN` repo secret — CI no longer needs private git access now - that the dependency is on crates.io. (The `ci.yml` push must go over SSH: the - gh OAuth token lacks `workflow` scope.) -- Verify the published docs render on docs.rs. +- Verify the published docs render on docs.rs: feature badges appear on the + gated modules (`v3_sync`, the per-protocol adapters) and no `sol!`-generated + ABI types leak into the item list. +- Confirm the README badges resolve (crates.io version, docs.rs). +- Configure the `E2E_RPC_URL` repo secret and run the `Live (env-gated) tests` + workflow once via `workflow_dispatch` to seed the weekly schedule. + (Reminder: any `.github/workflows/*` push must go over SSH — the gh OAuth + token lacks `workflow` scope.) ## Quick status @@ -114,6 +138,7 @@ cargo publish | CI matrix green vs published 0.2.1 (fmt / clippy×N / tests×3 / docs / isolation / dep-leak / MSRV 1.88) | ✅ | | License files present (`LICENSE-APACHE` + `LICENSE-MIT`) | ✅ | | `evm-fork-cache` resolvable from crates.io | ✅ | -| `cargo publish --dry-run` clean (50 files) | ✅ | -| Release work merged to `main` | ⏳ pending | -| Tagged `v0.1.0` + `cargo publish` | ⏳ pending | +| `cargo publish --dry-run` clean (56 files) | ✅ | +| Release work merged to `main` | ✅ (hardening tiers 0–2, #30/#31/#33, release-review polish) | +| `v0.1.0` tag re-pointed at the final commit | ⏳ pending (currently on `6187d50`, six merges behind) | +| `cargo publish` | ⏳ pending | diff --git a/ROADMAP.md b/ROADMAP.md index b3a8288..f61990e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,43 +20,48 @@ checkpoint before implementation. ## Release Plan -### 0.1.0 (current) +### 0.1.0 (current — release candidate) The adapters-and-cache-orchestration deliverable, feature-complete for five protocol families (Uniswap V2, the concentrated-liquidity family — Uniswap V3 / PancakeSwap V3 / Slipstream — Balancer V2, Solidly V2, and Curve) plus the -offline `simulate_swap` surface. Pools are supplied by the consumer -(`register_pool`); the crate does not yet discover them. - -### 0.2.0: Factory discovery (planned — full design in [docs/factory-discovery-spec.md](docs/factory-discovery-spec.md)) - -**Goal:** make pool discovery **declarative** — consumers say *what* they want -("the WETH/USDC 0.3% V3 pool", "every pool between WBTC/WETH") instead of -hand-registering addresses. Two halves over one vocabulary: - -- **Pull (primary UX):** resolve existing pools **derive-first** for the hot - protocols — compute the factory's `getPair`/`getPool` mapping slot in Rust - and point-read it (batchable; bulk watchlists ride the `storage_sync` - loader), with CREATE2 derivation as the zero-I/O path + cross-check — - falling back to executing the factory's own view function in revm for the - protocols that are hard to recreate (Curve MetaRegistry - `find_pools_for_coins`, unknown forks). Balancer (no on-chain pair index) - backfills via a Vault log scan helper. Returns cold-start-ready - `PoolRegistration`s. Fully unblocked today; needs nothing from the - upstream interests work. -- **Push:** subscribe to factory creation events (`PairCreated` / - `PoolCreated` / Vault `PoolRegistered`) and admit new pools between batches - via `AmmSyncEngine::register_pools` (rebuild-based now; incremental once the - `evm-fork-cache` interests-refresh API lands). - -Shape: a `PoolFactory` trait (queries + creation decode) built per protocol by -a **defaulted** `AmmAdapter::pool_factory(&FactoryConfig)` hook, fronted by -`PoolDiscovery::{find, find_all, creation_sources, decode_creation}`. -`FactoryConfig` mirrors `SimConfig` (mainnet defaults + `with_*` overrides). -Everything additive/`#[non_exhaustive]`; third-party adapters compile -unchanged. Bonus: Curve variant detection becomes registry *provenance* -instead of a heuristic. Slicing, per-protocol mechanics, and open questions -live in the spec. +offline `simulate_swap` surface — **including declarative factory discovery**, +which was originally slated for 0.2.0 and shipped early (design in +[docs/factory-discovery-spec.md](docs/factory-discovery-spec.md)): +`PoolDiscovery::{find, find_many}` over a fluent `PoolQuery` resolves pools +derive-first in one batched read across Uniswap V2 / the CL family / Solidly, +with optional CREATE2 cross-checks, creation-event decoding +(`DiscoverySource::CreationEvent`), a defaulted per-adapter +`AmmAdapter::pool_factories(&FactoryConfig)` hook, and first-class escape +hatches (`register_adapter`, `PoolDiscovery::with_factory`). Reactive sync +event-sources V2/Solidly `Sync`, V3 `Mint`/`Burn` onto warm ticks, and +Balancer vault `Swap`s onto probed cash fields; Balancer/Curve cold-starts +take a verify-only fast path once their read-set is known, and +`cold_start_many` bundles every one-shot-eligible pool into one hydration +call. + +### 0.1.x / 0.2.0 candidates (post-release) + +- **Access-list two-shot cold-start warming** — `eth_createAccessList` fast + first boot + `cold_start_primed` (implemented on PR #32; deliberately held + out of 0.1.0 to keep the release frozen; first candidate to land after). +- **Balancer V2 discovery** via an async Vault `PoolRegistered` log-scan + helper (Balancer has no on-chain token→pool index). +- **Curve discovery** via an in-EVM MetaRegistry `find_pools_for_coins` + view call (dropped from 0.1.0: the live registry call reverted under the + pinned-cache harness; needs its own transport shape). +- **Balancer `PoolBalanceManaged`**: subscribe + resync the asset-manager + cash↔managed rebalance event. Today the cash probe refuses managed fields + (a managed pool resyncs instead of event-sourcing), so exposure is + negligible — subscribing closes it fully. +- **Algebra-style CL forks** (Camelot / QuickSwap): a different pool engine + (dynamic fees, `globalState` packing, `tickTable`) — a new adapter, not a + discovery config. +- **Discovery-integrated `Bootstrapper`**: pair bulk discovery with + `cold_start_many` behind strategy knobs + (docs/high-performance-bootstrap-defaults.md). +- **Incremental interests refresh** on `AmmSyncEngine::register_pools` once + the `evm-fork-cache` interests API lands (rebuild-based today). ## Scope From bdc297abaa7b17acdd808c42bf31ace67bd2ae36 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Wed, 8 Jul 2026 10:13:11 +0100 Subject: [PATCH 12/14] fix: orphaned cfg attr hid ProtocolMetadata from solidly-only builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting factory.rs's 'use alloy_sol_types::sol;' left its cfg(uniswap-v2|uniswap-v3) attribute behind, which stacked onto the ProtocolMetadata import below and compiled it out of solidly-2-only builds — caught by the isolation clippy matrix, the exact bug class it exists for. Also allow the sim::abi re-export to be unused in the bare adapters-only build (all its consumers are feature-gated out there). Co-Authored-By: Claude Fable 5 --- src/adapters/factory.rs | 1 - src/adapters/sim.rs | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/adapters/factory.rs b/src/adapters/factory.rs index abddc8a..36d5905 100644 --- a/src/adapters/factory.rs +++ b/src/adapters/factory.rs @@ -40,7 +40,6 @@ use std::collections::HashMap; use std::fmt; -#[cfg(any(feature = "uniswap-v2", feature = "uniswap-v3"))] #[cfg(any(feature = "uniswap-v2", feature = "uniswap-v3", feature = "solidly-v2"))] use super::ProtocolMetadata; #[cfg(feature = "solidly-v2")] diff --git a/src/adapters/sim.rs b/src/adapters/sim.rs index 4bbb9cb..d078780 100644 --- a/src/adapters/sim.rs +++ b/src/adapters/sim.rs @@ -292,4 +292,8 @@ pub(crate) mod abi { } } +// In a bare `adapters`-only build every consumer (the per-protocol +// `simulate_swap` impls) is compiled out, leaving this re-export unused — +// that build is expected, not a bug. +#[allow(unused_imports)] pub(crate) use abi::*; From 3f973b6caff7a32b0aa76917974882c0299dfdd4 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Wed, 8 Jul 2026 10:17:15 +0100 Subject: [PATCH 13/14] docs(roadmap): interests refresh is adoption work, not an upstream blocker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit evm-fork-cache 0.2 already ships the incremental interests API (ReactiveRuntime::unregister_handler; add_interest_owner(_with_backfill) / remove_interest_owner / sync_handler_interests on the subscription engine). The stale 'once the upstream API lands' framing survived from the pre-0.2 roadmap; the real remaining work is on this side — AmmSyncEngine still rebuilds a fresh ReactiveRuntime per registry change, discarding reorg tracking, instead of refreshing the handler registration in place. Co-Authored-By: Claude Fable 5 --- ROADMAP.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f61990e..2f03171 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,8 +60,15 @@ call. - **Discovery-integrated `Bootstrapper`**: pair bulk discovery with `cold_start_many` behind strategy knobs (docs/high-performance-bootstrap-defaults.md). -- **Incremental interests refresh** on `AmmSyncEngine::register_pools` once - the `evm-fork-cache` interests API lands (rebuild-based today). +- **Incremental interests refresh in `AmmSyncEngine`** — the upstream API + already shipped in `evm-fork-cache` 0.2 (`ReactiveRuntime::unregister_handler` + plus per-owner `add_interest_owner(_with_backfill)` / `remove_interest_owner` + / `sync_handler_interests` on the subscription engine); what remains is + adoption **here**: `register_pools` / `replace_registry` still construct a + fresh `ReactiveRuntime` per registry change, discarding accumulated reorg + tracking between batches. Refresh the `AmmReactiveHandler` registration on + the live runtime instead, and thread backfill-aware owner updates through + for subscriber-driven callers. ## Scope From 2ba13759d9af38cb5a3be68a7b5b487a0f2ab823 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Wed, 8 Jul 2026 10:20:22 +0100 Subject: [PATCH 14/14] fix: gate the README doctest include on uniswap-v3 The quickstart registers the V3-family adapter, so compiling it under --no-default-features failed the doctest phase (caught by the battery's CI-parity test-nodefault gate). Default and all-features runs still compile it. Co-Authored-By: Claude Fable 5 --- src/lib.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8937388..149feb0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,7 +56,9 @@ pub mod adapters; /// Compiles the README's code samples as doctests so the quickstart cannot /// drift from the real API. Exists only under `cfg(doctest)` — it is never -/// part of the built crate or the rendered docs. -#[cfg(doctest)] +/// part of the built crate or the rendered docs. Gated on `uniswap-v3` +/// because the quickstart registers the V3-family adapter; the default and +/// all-features test runs (local + CI) still compile it. +#[cfg(all(doctest, feature = "uniswap-v3"))] #[doc = include_str!("../README.md")] pub struct ReadmeDoctests;