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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .cargo/audit.toml
Original file line number Diff line number Diff line change
@@ -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",
]
47 changes: 31 additions & 16 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -98,18 +105,24 @@ 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**

- Unit, offline reactive, cold-start adoption, and full register→cold-start→
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.
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
16 changes: 14 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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).
Expand Down
80 changes: 70 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
| --- | --- |
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading