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: 12 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,11 @@ register → cold-start → subscribe → react → simulate.
exact masked reserve write (event-sourced, no refetch).
- **Uniswap V3 family** (V3, PancakeSwap V3, Slipstream) — `QuoterV2`
quotes; slot0 + liquidity + a bounded, fixed-radius **multi-word tick-window
warm-up** at cold-start; `Swap` → slot0/liquidity, `Mint`/`Burn` → tick-range
resync.
warm-up** at cold-start; `Swap` → slot0/liquidity. `Mint`/`Burn` are
**event-sourced**: the exact `liquidityGross`/`liquidityNet` (packed word 0),
`tickBitmap` bit, and in-range global `liquidity` are written directly from the
event for warm (in-window) ticks with no RPC, and only genuinely-cold ticks
fall back to a targeted resync.
- **Balancer V2** — `Vault.queryBatchSwap` quotes; discover→verify cold-start
(`getPoolTokens` read-set); `Swap` → balance-slot resync.
- **Solidly V2** (Aerodrome / Velodrome) — pool `getAmountOut` quotes;
Expand All @@ -91,10 +94,13 @@ register → cold-start → subscribe → react → simulate.
quote entrypoint inside a local revm against the warmed cache, then decodes the
result. There is **no reimplemented AMM math**.

**Reactive synchronization** — fully offline (no RPC in the hot path). Pools
whose events carry absolute state are event-sourced with exact writes (Uniswap
V2 / Solidly `Sync`); pools whose events carry deltas re-verify just the
affected slots (Uniswap V3 tick ranges, Balancer / Curve `VerifySlots`).
**Reactive synchronization** — fully offline (no RPC in the hot path) for the
common case. Pools whose events carry absolute state are event-sourced with exact
writes (Uniswap V2 / Solidly `Sync`); Uniswap V3 `Mint`/`Burn` are event-sourced
too, applying the exact liquidity delta to the warmed tick/bitmap/liquidity slots
and resyncing only ticks outside the warmed window; Balancer / Curve events carry
deltas over a non-predictable layout and re-verify the discovered slots
(`VerifySlots`).

**Testing & CI**

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

17 changes: 17 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] }
# `plotters` deps — keeps the build lean and, since the crate deliberately ships
# without rayon, avoids reintroducing it even in the dev/bench build.
criterion = { version = "0.5", default-features = false }
# The live V3-liquidity parity test parses `trace_replayTransaction` stateDiff as
# raw JSON (per-tx storage before/after ground truth). Dev-only.
serde_json = "1.0"

# Runnable adapters-path demo: register -> cold-start -> WS event subscribe ->
# reactive apply -> simulate_swap. Env-gated; no-ops if the RPC/WS URL is unset.
Expand Down Expand Up @@ -194,6 +197,13 @@ required-features = ["uniswap-v3"]
name = "v3_full_sync_rpc"
required-features = ["uniswap-v3"]

# Live parity for event-sourced V3 Mint/Burn (env-gated, #[ignore]): for a real
# add- and remove-liquidity tx, apply the event and assert the adapter's writes
# reproduce the on-chain per-tx storage diff (trace_replayTransaction stateDiff).
[[test]]
name = "v3_liquidity_rpc"
required-features = ["uniswap-v3"]

[[test]]
name = "reactive_ws_e2e"
required-features = ["uniswap-v2"]
Expand Down Expand Up @@ -255,6 +265,13 @@ required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2", "curve"]
name = "trace_resync_latency"
required-features = ["curve"]

# Curve cold-start phase breakdown: discovery (slow first boot) vs verify-only
# cold_start vs cold_start_many, once the read-set is known. Env-gated; defaults
# to a public Ethereum endpoint when E2E_RPC_URL is unset.
[[example]]
name = "curve_cold_start_phases"
required-features = ["curve"]

# End-to-end arbitrage examples (env-gated; need an archive RPC to warm state).
[[example]]
name = "arbitrage_cross_dex"
Expand Down
59 changes: 44 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@

`evm-amm-state` is a real-time AMM state engine built on a forked-EVM state
cache ([`evm-fork-cache`]). It tracks a working set of pools, **cold-starts**
their on-chain state into the cache, keeps them current **purely from chain log
events** (no RPC in the hot path), and runs fast, **fully-offline swap
simulations** against the live-synced state.
their on-chain state into the cache, and keeps them current **from chain log
events**: protocols whose events carry absolute state (Uniswap V2 / Solidly
`Sync`) are updated with **no RPC at all**, while protocols whose events carry
only deltas (Uniswap V3 liquidity, Balancer, Curve) turn each event into a
bounded, hash-pinned storage **resync** (block trace first, then bulk-storage /
point-read fallback). Once a pool's quote read-set is warmed and current, swap
**simulations run fully offline** against the live-synced state.

The defining design choice: **no reimplemented AMM math.** Every quote runs the
pool's *own* canonical on-chain quote entrypoint inside a local revm against the
warmed cache (e.g. Uniswap `QuoterV2`, Curve `get_dy`), then decodes the result.
There is no `LocalAMM`/`amm-math` formula layer to drift from the real contracts.
protocol's *canonical* on-chain quote entrypoint inside a local revm against the
warmed cache — the pool's own `get_dy` / `getAmountOut`, or the protocol's
official router/quoter (Uniswap `QuoterV2` / `Router02`) — then decodes the
result. There is no `LocalAMM`/`amm-math` formula layer to drift from the real
contracts.

[`evm-fork-cache`]: https://github.com/KaiCode2/evm-fork-cache

Expand Down Expand Up @@ -40,14 +46,25 @@ Each protocol is a single [`AmmAdapter`] implementation; the
| Protocol | Feature | Quote entrypoint | Cold-start | Reactive |
| --- | --- | --- | --- | --- |
| Uniswap V2 | `uniswap-v2` | `Router02.getAmountsOut` | named slots | `Sync` → exact masked write |
| Uniswap V3 family (V3, PancakeSwap V3, Slipstream) | `uniswap-v3` (`pancake-v3`, `slipstream`) | `QuoterV2.quoteExactInputSingle` | slot0 + liquidity + multi-word tick scan (per-pool radius), or the one-shot full-range program sync (`v3_sync`) | `Swap` → slot0/liquidity; `Mint`/`Burn` → tick-range resync |
| Uniswap V3 family (V3, PancakeSwap V3, Slipstream) | `uniswap-v3` (`pancake-v3`, `slipstream`) | `QuoterV2.quoteExactInputSingle` | slot0 + liquidity + multi-word tick scan (per-pool radius), or the one-shot full-range program sync (`v3_sync`) | `Swap` → slot0/liquidity; `Mint`/`Burn` → exact tick + global-liquidity writes where warm, resync only cold ticks |
| Balancer V2 | `balancer-v2` | `Vault.queryBatchSwap` | discover → verify (`getPoolTokens`) | `Swap` → balance-slot resync |
| Solidly V2 (Aerodrome / Velodrome) | `solidly-v2` | pool `getAmountOut` | named slots (config layout) | `Sync` → two exact slot writes |
| **Curve** (StableSwap, StableSwap-NG, CryptoSwap v2, Tricrypto-NG) | `curve` | pool `get_dy` | discover → verify (`get_dy` read-set) | `TokenExchange` + liquidity events → slot resync |

All protocol features are on by default. See [`docs/curve-adapter.md`](docs/curve-adapter.md)
All protocol features are on by default. See
[`docs/protocol-support-matrix.md`](docs/protocol-support-matrix.md) for the
per-protocol capability matrix (offline-after-cold-start, exact-write vs resync,
discovery, and known limitations), and [`docs/curve-adapter.md`](docs/curve-adapter.md)
for the Curve adapter in depth.

> **Solidly offline caveat.** Solidly's `getAmountOut` reads more than the
> reserves its cold-start warms — the pool's `stable` flag and token `decimals`,
> plus an external `IPoolFactory(factory).getFee()` STATICCALL (which needs the
> factory's code and fee slots). With a live-backed cache these fetch lazily on
> the first quote; for fully-offline Solidly quotes, keep a backend attached or
> pre-warm that read-set. Uniswap V2/V3 and Curve cold-starts already cover their
> quote read-set (V3 within its warmed tick window).

### Verified Pool Bytecode Seeding

Known pool runtime bytecodes live in [`src/adapters/bytecodes`](src/adapters/bytecodes)
Expand All @@ -70,9 +87,14 @@ entirely with `AdapterRegistry::with_code_seeding(false)`.
Uniswap V3 has an embedded pool template and an explicit `uniswap_v3_code_seed`
helper for callers that already know the pool immutables. Factory-discovered
Uniswap V3 registrations carry the factory immutable in metadata, allowing
automatic V3 seeding without assuming a chain-global factory address. Balancer
and Curve pool bytecode seeding are also in scope for this bytecode workstream
before it is considered complete.
automatic V3 seeding without assuming a chain-global factory address. Bytecode
seeding covers Uniswap V2 and the V3 family from embedded/rendered templates.
Balancer and Curve pools have no shared template, so by default they fetch their
runtime code lazily on first simulate — but **Curve accepts an optional
caller-supplied seed** via `CurveMetadata::with_code_seed(runtime)` for callers
that already know a pool's Vyper runtime (verified once against on-chain code,
same purge-on-mismatch contract). Since seeding is a pure optimization over that
lazy fetch, this is only a latency difference, never a correctness one.

### Factory-backed Discovery

Expand Down Expand Up @@ -150,10 +172,12 @@ per-pair fallback.
`AdapterRegistry::cold_start_many(pools, cache, provider, policy)` warms many
pools at once: it seeds + verifies all one-shot-eligible pools' code in one
account-fields call, hydrates them through a single bundled `run_storage_programs`
`eth_call` (V3 full-sync / V2 flat-slot), and finalizes them `Ready`, falling
back per pool to the conservative per-pool `cold_start` for anything without a
one-shot program or whose hydration fails. `supports_one_shot_hydration`
reports which pools take the fast path. Combined with token-basket discovery,
`eth_call` (V3 full-sync / V2 flat-slot / Balancer or **Curve** discovered
read-set), and finalizes them `Ready`, falling back per pool to the conservative
per-pool `cold_start` for anything without a one-shot program or whose hydration
fails. `supports_one_shot_hydration` reports which pools take the fast path — a
Curve pool qualifies once its `discovered_slots` read-set is known (from a prior
discovery, a trace, or a registry), joining V2/V3 in the same bundled call. Combined with token-basket discovery,
the happy path is `find(PoolQuery::basket(..)) → cold_start_many → register`,
with request count driven by bootstrap phases rather than pool count.

Expand Down Expand Up @@ -369,6 +393,11 @@ cargo test # unit + offline integration tests
cargo test --no-default-features # protocol-neutral core
```

These run from a clone of the repository. The published crate **excludes the
integration test suite** (`tests/`) to stay lean, so `cargo test` on a crates.io
download exercises only the inline unit tests — clone the repo for the full
suite.

Network-dependent tests are env-gated and `#[ignore]`d. With an archive RPC they
pin a block, cold-start a real pool, and assert `simulate_swap` **equals the
on-chain quote** at the same block (`eth_call`), plus a live WebSocket soak that
Expand Down
37 changes: 37 additions & 0 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ qualitatively, to the other ways people price AMM swaps.

## Results

> Point-in-time medians from a single host and run (mid-2026, the Methodology
> host above) — treat them as order-of-magnitude, and re-run the reproduce
> command for numbers on your own machine. `cargo bench` prints full Criterion
> statistics (mean / median / std-dev / outliers) to stdout and writes HTML
> reports under `target/criterion/`; the medians below are the headline figures
> from that output.

### `simulate_swap` — one offline quote (the repeated hot path)

| Protocol | Quote entrypoint | Median / quote | ≈ Quotes/sec |
Expand Down Expand Up @@ -104,6 +111,36 @@ Interpretation:
`debug_traceBlockByNumber` integration should populate it from traces, avoiding
the view-call discover round and keeping the one-shot refresh path.

### Curve cold-start: discovery vs a known read-set

A Curve pool's *first* cold start is a discover→verify run: it fetches the pool's
Vyper runtime and executes `get_dy` in a local revm over a cold cache, lazily
faulting in each slot it SLOADs. That first-discovery cost — not warmed quoting —
is what makes a cold Curve boot lag Uniswap V2/V3, whose hot state is a known slot
set (or tick-bitmap program) hydrated in one bundled `eth_call`.

Once the read-set is known, the gap closes to the one-shot figures above (the
same Curve 3pool row: **~361 ms → ~75 ms**). Two paths reuse a persisted
`CurveMetadata.discovered_slots` (from a prior discovery, a block trace, or a
registry):

- **verify-only `cold_start`** — the planner skips discovery and warms exactly
the known slots in a single verify round;
- **`cold_start_many`** — the same read-set becomes one bundled storage program,
the identical fast path Uniswap V2/V3 take.

[`examples/curve_cold_start_phases.rs`](../examples/curve_cold_start_phases.rs)
times all three (discovery vs verify-only vs `cold_start_many`) against a live
pool and prints the breakdown — run it for numbers on your own endpoint:

```bash
E2E_RPC_URL=<archive-url> cargo run --release --example curve_cold_start_phases
```

The optional `CurveMetadata::with_code_seed` removes the one lazy code fetch a
Curve pool otherwise pays on its first quote, matching the fully-offline V2/V3
profile after bootstrap.

### Event-time trace resync

[`examples/trace_resync_latency.rs`](../examples/trace_resync_latency.rs)
Expand Down
64 changes: 52 additions & 12 deletions docs/curve-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,29 @@ reimplemented Curve math**.
```rust
use evm_amm_state::adapters::{CurveMetadata, CurveVariant, PoolKey, PoolRegistration, ProtocolMetadata};

// Minimal: coins (index order) + dialect. Cold-start discovers the read-set.
let reg = PoolRegistration::new(PoolKey::Curve(pool_address))
.with_state_address(pool_address)
.with_metadata(ProtocolMetadata::Curve(CurveMetadata {
// Coins in index order; coins[i] is the get_dy index `i`. Config-supplied
// (static pool identity); drives the simulate_swap token -> index mapping.
coins: vec![dai, usdc, usdt],
// Populated by cold-start (the get_dy read-set). Leave empty.
discovered_slots: Vec::new(),
// The dialect — selects the get_dy ABI and the event set.
variant: CurveVariant::StableSwap,
}));
.with_metadata(ProtocolMetadata::Curve(
CurveMetadata::default()
// coins[i] is the get_dy index `i`; config-supplied static pool
// identity, drives the simulate_swap token -> index mapping.
.with_coins(vec![dai, usdc, usdt])
// The dialect — selects the get_dy ABI and the event set.
.with_variant(CurveVariant::StableSwap),
));

// Fast reboot: pre-fill the read-set (from a prior discovery / trace / registry)
// to skip discovery, and optionally seed the pool runtime. See "Cold-start".
// CurveMetadata::default()
// .with_coins(vec![dai, usdc, usdt])
// .with_discovered_slots(known_slots) // verify-only cold_start + cold_start_many
// .with_code_seed(pool_runtime) // no lazy code fetch at first quote
```

(`CurveMetadata` is `#[non_exhaustive]`; construct it via `default()` + the
`with_*` builders rather than a struct literal.)

`CurveVariant` (defaults to `StableSwap`, so classic + NG pools need no flag):

| Variant | Use for | `get_dy` indices | `TokenExchange` | Liquidity events |
Expand All @@ -52,23 +62,53 @@ quote path (differing only by the 3-arg `RemoveLiquidityOne`, which both route);
CryptoSwap/Tricrypto-NG share the `uint256` quote path (differing only in
events).

## Cold-start — discover → verify
## Cold-start — discover → verify, or verify-only

A real Curve pool has **no predictable balance-slot layout** (a probe confirmed
`balances[]` is not at a fixed slot — it varies by Vyper build), so the planner
does not hand-code slots. Instead it mirrors `BalancerV2ColdStartPlanner`:
does not hand-code slots. It runs in one of two modes.

**Discover → verify** — the read-set is unknown (`discovered_slots` empty),
mirroring `BalancerV2ColdStartPlanner`:

1. **Discover** — run `get_dy(0, 1, DISCOVER_DX)` against the pool with
`restrict_to=[pool]`, capturing the exact storage slots it SLOADs (balances +
amplification + fee, wherever they live). The discover call uses the variant's
`get_dy` ABI (a CryptoSwap pool reverts the `int128` form).
2. **Verify** — authoritatively warm those captured slots.
3. **finish** — persist `coins` + `discovered_slots` + `variant`, status `Ready`.
3. **finish** — persist `coins` + `discovered_slots` + `variant` (+ any
`code_seed`), status `Ready`.

Repairs mirror Balancer: a reverting/empty discover → re-run cold-start; an
archive-miss on a discovered slot → `VerifySlots`; a per-slot `SlotFetch`
distinguishes a genuine zero from a fetch failure.

**Verify-only** — the read-set is already known (`discovered_slots` pre-populated
from a prior discovery, a block trace, or a registry). The planner **skips
discovery entirely** — no pool-account/bytecode fetch and no cold-cache `get_dy`
faulting — and warms exactly the known slots in a **single verify round**. This
is what makes a known-read-set `cold_start` as cheap as the bundled
`cold_start_many` storage-program path (the same one-shot hydration Uniswap V2/V3
use), and it makes the pool eligible for `cold_start_many` /
`supports_one_shot_hydration`. A stale/incomplete set is safe: verify refreshes
what it has and the first `simulate_swap` lazily faults anything missing. See
[`examples/curve_cold_start_phases.rs`](../examples/curve_cold_start_phases.rs)
for a live discovery-vs-verify-only-vs-`cold_start_many` breakdown.

### Bytecode seeding (optional)

Curve pools are per-pool Vyper builds with **no shared or renderable template**
(unlike Uniswap V2's shared pair runtime or V3's rendered template), so the crate
embeds no Curve seed. A caller that already knows a pool's runtime can attach it
via [`CurveMetadata::with_code_seed`]: cold-start (and `cold_start_many`) verify
it once against the on-chain `EXTCODEHASH` — a mismatch is purged and the pool
falls back to lazily fetching the real code, so a wrong seed is a latency
question, never a correctness one. Seeding removes the one lazy code fetch a Curve
pool otherwise pays on its first `simulate_swap`, matching the fully-offline
V2/V3 profile after bootstrap.

[`CurveMetadata::with_code_seed`]: https://docs.rs/evm-amm-state/latest/evm_amm_state/adapters/struct.CurveMetadata.html

## Reactive — resync (not event-sourcing)

**Curve state cannot be kept current purely from events** (unlike Uniswap V2,
Expand Down
Loading
Loading