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", +] diff --git a/CHANGELOG.md b/CHANGELOG.md index 42f70d9..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 @@ -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/Cargo.toml b/Cargo.toml index fc8550b..b81d96b 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. @@ -169,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" @@ -228,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/README.md b/README.md index 23237ec..f87beaf 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,20 @@ # 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 -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 @@ -19,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 @@ -47,16 +78,23 @@ 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 | -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 @@ -105,7 +143,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 | | --- | --- | @@ -198,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}; @@ -308,7 +346,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 @@ -386,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/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..2f03171 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,43 +20,55 @@ 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 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 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 4efa1d7..f358e00 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -23,18 +23,25 @@ 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 -> 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) @@ -62,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 @@ -147,7 +166,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/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 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/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/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/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()) 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/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/balancer_v2.rs b/src/adapters/balancer_v2.rs index 5e6857d..cb22164 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 { @@ -880,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/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/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..36d5905 100644 --- a/src/adapters/factory.rs +++ b/src/adapters/factory.rs @@ -40,14 +40,6 @@ 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 +53,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 +347,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 @@ -1307,35 +1315,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/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/adapters/sim.rs b/src/adapters/sim.rs index 0041c5a..d078780 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,108 @@ 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); + } } } + +// 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::*; 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/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/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..4e776eb 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…` @@ -1015,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/src/lib.rs b/src/lib.rs index dbf6964..149feb0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,38 @@ // 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; + +/// 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. 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; 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/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/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(()) +} diff --git a/tests/bytecode_golden.rs b/tests/bytecode_golden.rs index cbb9a09..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`, @@ -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()) } 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");