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
7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,13 @@ required-features = ["uniswap-v2", "uniswap-v3", "balancer-v2", "curve"]
name = "trace_resync_latency"
required-features = ["curve"]

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

# End-to-end arbitrage examples (env-gated; need an archive RPC to warm state).
[[example]]
name = "arbitrage_cross_dex"
Expand Down
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,13 @@ Uniswap V3 has an embedded pool template and an explicit `uniswap_v3_code_seed`
helper for callers that already know the pool immutables. Factory-discovered
Uniswap V3 registrations carry the factory immutable in metadata, allowing
automatic V3 seeding without assuming a chain-global factory address. Bytecode
seeding covers Uniswap V2 and the V3 family; Balancer and Curve pools have no
embedded seed and simply fetch their runtime code lazily on first simulate. Since
seeding is a pure optimization over that lazy fetch, this is only a latency
difference, never a correctness one.
seeding covers Uniswap V2 and the V3 family from embedded/rendered templates.
Balancer and Curve pools have no shared template, so by default they fetch their
runtime code lazily on first simulate — but **Curve accepts an optional
caller-supplied seed** via `CurveMetadata::with_code_seed(runtime)` for callers
that already know a pool's Vyper runtime (verified once against on-chain code,
same purge-on-mismatch contract). Since seeding is a pure optimization over that
lazy fetch, this is only a latency difference, never a correctness one.

### Factory-backed Discovery

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

Expand Down
30 changes: 30 additions & 0 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,36 @@ Interpretation:
`debug_traceBlockByNumber` integration should populate it from traces, avoiding
the view-call discover round and keeping the one-shot refresh path.

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

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

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

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

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

```bash
E2E_RPC_URL=<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