From 9090c4178269a473ba614f3ad18afebfcd498d4f Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 14:05:19 +0100 Subject: [PATCH 1/2] feat(uniswap-v3): event-source Mint/Burn liquidity (no RPC where warm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uniswap V3 `Mint`/`Burn` previously always emitted a tick-range resync (an RPC round trip). They now event-source the affected state directly wherever it is already warm, matching the exact-write posture of the V2/Solidly `Sync` path, and resync ONLY for ticks outside the warmed window. The event carries the exact liquidity delta and the boundary ticks; the current tick comes from cached `slot0`. For each warm boundary tick the adapter read-modify-writes the packed `Tick.Info` word 0 — `liquidityGross` (low 128) and `liquidityNet` (high 128, opposite signs for the lower vs upper tick) — and toggles the `tickBitmap` bit on an init/clear (Uniswap `flipTick` is an XOR); the in-range global `liquidity` slot is adjusted by `±amount`. Those are exactly the slots a `QuoterV2` swap reads; `feeGrowthOutside`/`positions` are accounting-only (they do not affect `amountOut`) and are intentionally not maintained. A cold boundary tick (word 0 not cached) cannot be read-modify-written, so its info + bitmap slots fall back to a `VerifySlots` resync — the hybrid write-where-warm / resync-cold policy. No-layout pools still degrade to a conservative whole-storage invalidation. Correctness rests on the reactive runtime applying each input's updates before the next input's decode (verified in `ingest_batch_direct`), so read-modify-write sees prior events in the batch. Bitmap flips for both ticks in a shared word are accumulated into one combined write (two full-slot writes from the same pre-event view would not compose). Tests: 9 inline unit tests (packing round-trip incl. negative net, all four mint/burn sign combos, init/clear detection, overflow rejection, bit-position vs Uniswap `position`); reactive integration tests for warm direct writes, out-of-range liquidity, burn-to-zero bitmap clear (shared word), and cold-tick resync fallback; Pancake/Slipstream layout coverage retained. `RepairAction:: V3TickRange` is retained as a reserved variant. Full gate green (tests all/default/no-default, clippy -D warnings ×2, doc, fmt, missing_docs=0). Follow-up: an env-gated RPC parity test (apply a real Mint/Burn, compare event-sourced slots to eth_getStorageAt ground truth at the post-event block) — deferred pending an archive endpoint to author it against. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 18 +- README.md | 2 +- docs/protocol-support-matrix.md | 2 +- src/adapters/uniswap_v3.rs | 352 ++++++++++++++++++++++++-- tests/adapter_reactive.rs | 426 +++++++++++++++++++++++++++----- 5 files changed, 710 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82328ff..42f70d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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; @@ -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** diff --git a/README.md b/README.md index 15fc29b..ea69479 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ 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 | diff --git a/docs/protocol-support-matrix.md b/docs/protocol-support-matrix.md index 54d000d..4f9b6ce 100644 --- a/docs/protocol-support-matrix.md +++ b/docs/protocol-support-matrix.md @@ -10,7 +10,7 @@ backend or extra setup is still needed. This complements the summary table in th | Protocol (feature) | Cold-start | Offline quote after cold-start | Reactive event → state | Factory discovery | Known limitations | | --- | --- | --- | --- | --- | --- | | **Uniswap V2** (`uniswap-v2`) | named slots (token0/1, reserves) | ✅ fully offline | `Sync` → **exact** masked write, no RPC | ✅ `getPair[t0][t1]` | — | -| **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` → bounded tick-range **resync** | ✅ fee-keyed `getPool[t0][t1][fee]` | — | +| **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 | diff --git a/src/adapters/uniswap_v3.rs b/src/adapters/uniswap_v3.rs index b05d755..15356bd 100644 --- a/src/adapters/uniswap_v3.rs +++ b/src/adapters/uniswap_v3.rs @@ -163,7 +163,7 @@ impl AmmAdapter for ConcentratedLiquidityAdapter { &self, pool: &PoolRegistration, log: &Log, - _view: &dyn StateView, + view: &dyn StateView, ) -> AdapterEventResult { let Some(topic0) = log.topics().first().copied() else { return AdapterEventResult::ignored(); @@ -174,9 +174,9 @@ impl AmmAdapter for ConcentratedLiquidityAdapter { } else if topic0 == PancakeV3Swap::SIGNATURE_HASH { self.decode_swap(pool, log, topic0, SwapAbi::Pancake) } else if topic0 == Mint::SIGNATURE_HASH { - self.decode_tick_range_repair(pool, log, true) + self.decode_liquidity_event(pool, log, view, true) } else if topic0 == Burn::SIGNATURE_HASH { - self.decode_tick_range_repair(pool, log, false) + self.decode_liquidity_event(pool, log, view, false) } else { AdapterEventResult::ignored() } @@ -370,10 +370,33 @@ impl ConcentratedLiquidityAdapter { }) } - fn decode_tick_range_repair( + /// Decode a Uniswap V3 `Mint`/`Burn` and **event-source** the affected state + /// directly wherever it is already warm — no RPC — falling back to a targeted + /// resync only for boundary ticks whose base value is cold (outside the warmed + /// window). + /// + /// The event carries the exact liquidity delta (`amount`) and the boundary + /// ticks; the current tick comes from cached `slot0`. For each **warm** + /// boundary tick this read-modify-writes the packed `Tick.Info` word 0 + /// (`liquidityGross` in the low 128 bits, `liquidityNet` in the high 128 — + /// moving in *opposite* directions for the lower vs. upper tick) and toggles + /// the `tickBitmap` bit when the tick initializes or clears (the contract's + /// `flipTick` is exactly an XOR of that bit). The global `liquidity` slot is + /// adjusted by `±amount` when the position straddles the current tick. Those + /// are precisely the slots a `QuoterV2` swap reads; `feeGrowthOutside` and the + /// `positions` mapping are accounting-only (they do not affect `amountOut`) and + /// are intentionally not maintained here. + /// + /// A **cold** boundary tick (its word 0 not cached) cannot be + /// read-modify-written, so its info + bitmap slots are emitted as a + /// [`RepairAction::VerifySlots`] resync instead — the hybrid write-where-warm / + /// resync-cold policy. A pool with no resolvable layout falls back to a + /// conservative whole-storage invalidation. + fn decode_liquidity_event( &self, pool: &PoolRegistration, log: &Log, + view: &dyn StateView, is_mint: bool, ) -> AdapterEventResult { let decode_ok = if is_mint { @@ -397,6 +420,8 @@ impl ConcentratedLiquidityAdapter { "missing V3 tickUpper topic", )); }; + let tick_lower = topic_to_i32(tick_lower_topic); + let tick_upper = topic_to_i32(tick_upper_topic); let topic0 = if is_mint { Mint::SIGNATURE_HASH @@ -408,22 +433,157 @@ impl ConcentratedLiquidityAdapter { } else { AdapterEventKind::LiquidityRemoved }; - let tick_lower = topic_to_i32(tick_lower_topic); - let tick_upper = topic_to_i32(tick_upper_topic); - AdapterEventResult::event(AdapterEvent { - pool: pool.key.clone(), - emitter: log.address, - topic0, - kind, - updates: Vec::new(), - quality: UpdateQuality::RequiresRepair, - repair: RepairAction::V3TickRange { - pool: pool.key.clone(), - tick_lower, - tick_upper, - }, - }) + let Some(address) = pool.key.address() else { + return AdapterEventResult::error(AdapterEventError::MalformedLog( + "V3 pool key is not address-keyed", + )); + }; + + // The `amount` (uint128 liquidity L) is the first NON-indexed data word: + // index 1 for Mint (a non-indexed `sender` precedes it) and index 0 for + // Burn (no leading non-indexed field). + let amount_word_index = if is_mint { 1 } else { 0 }; + let Some(amount_word) = data_word(log, amount_word_index) else { + return AdapterEventResult::error(AdapterEventError::MalformedLog( + "missing V3 liquidity amount", + )); + }; + let amount = u128_low(amount_word); + + // Without a resolvable layout the protocol slots cannot be named safely, so + // conservatively invalidate all of the pool's storage (prior behavior). + let Some(layout) = layout_for(pool) else { + return AdapterEventResult::event( + AdapterEvent::new( + pool.key.clone(), + log.address, + topic0, + kind, + UpdateQuality::RequiresRepair, + ) + .with_repair(RepairAction::PurgeStorage(address)), + ); + }; + + let mut updates: Vec = Vec::new(); + let mut resync: Vec<(Address, U256)> = Vec::new(); + + // Current tick from cached slot0 drives the in-range check for the global + // liquidity. slot0 is a mandatory cold-start slot; the boundary-tick writes + // below are independent of it. + let current_tick = view + .storage(address, layout.slot0_slot) + .map(|slot0| int24_from_word(slot0 >> SLOT0_TICK_SHIFT)); + + match current_tick { + // In range: apply ±amount to the warm global liquidity, or resync it. + Some(tick) if tick_lower <= tick && tick < tick_upper => { + match view.storage(address, layout.liquidity_slot) { + Some(old) => { + let new = if is_mint { + old.saturating_add(U256::from(amount)) + } else { + old.saturating_sub(U256::from(amount)) + }; + updates.push(StateUpdate::slot(address, layout.liquidity_slot, new)); + } + None => resync.push((address, layout.liquidity_slot)), + } + } + // Out of range: the position does not straddle the current tick, so the + // global liquidity is unaffected. + Some(_) => {} + // slot0 cold (a degraded pool): the in-range decision cannot be made, so + // conservatively resync the global liquidity slot to its on-chain truth + // rather than silently dropping a possible delta (self-healing, and it + // correctly forces RequiresRepair). + None => resync.push((address, layout.liquidity_slot)), + } + + // Bitmap-bit flips are accumulated per bitmap word as an XOR mask, then + // emitted as ONE combined write per word below. Both boundary ticks can + // land in the same word; two separate full-slot writes — each computed + // from the same pre-event `view` — would not compose (the second would + // clobber the first), so they must be merged before writing. + let mut bitmap_toggles: Vec<(U256, U256)> = Vec::new(); + + // Each boundary tick: read-modify-write the packed `Tick.Info` word 0 (and + // record a bitmap-bit flip on an init/clear) when warm; resync when cold. + for (tick, is_lower) in [(tick_lower, true), (tick_upper, false)] { + let keys = v3_tick_info_storage_keys_with_base(tick, layout.ticks_base_slot); + let word_pos = v3_word_position(tick, layout.tick_spacing); + let bitmap_key = + v3_tick_bitmap_storage_key_with_base(word_pos, layout.tick_bitmap_base_slot); + + let cold_fallback = |resync: &mut Vec<(Address, U256)>| { + resync.extend(keys.iter().map(|slot| (address, *slot))); + resync.push((address, bitmap_key)); + }; + + let Some(old_word0) = view.storage(address, keys[0]) else { + // Cold tick: cannot read-modify-write; resync its info + bitmap slots. + cold_fallback(&mut resync); + continue; + }; + + let Some((new_word0, was_init, now_init)) = + apply_liquidity_delta(old_word0, amount, is_mint, is_lower) + else { + // Arithmetic out of range (should not happen for valid chain data): + // resync this tick rather than write a wrong value. + cold_fallback(&mut resync); + continue; + }; + + updates.push(StateUpdate::slot(address, keys[0], new_word0)); + + // The bitmap bit flips exactly when the tick's initialized state + // changes (Uniswap `flipTick` XORs the bit). + if was_init != now_init { + if view.storage(address, bitmap_key).is_some() { + let mask = U256::from(1u8) << v3_bit_position(tick, layout.tick_spacing); + match bitmap_toggles + .iter_mut() + .find(|(key, _)| *key == bitmap_key) + { + Some((_, acc)) => *acc ^= mask, + None => bitmap_toggles.push((bitmap_key, mask)), + } + } else { + // Cold bitmap word: cannot toggle without the base; resync it. + resync.push((address, bitmap_key)); + } + } + } + + // Emit one combined write per touched bitmap word (base XOR accumulated + // mask), so both ticks' flips in a shared word compose correctly. + for (bitmap_key, mask) in bitmap_toggles { + match view.storage(address, bitmap_key) { + Some(base) => updates.push(StateUpdate::slot(address, bitmap_key, base ^ mask)), + None => resync.push((address, bitmap_key)), + } + } + + // Dedup the resync set (both boundary ticks can share a bitmap word). + resync.sort_unstable(); + resync.dedup(); + + let (quality, repair) = if resync.is_empty() { + (UpdateQuality::Exact, RepairAction::None) + } else { + ( + UpdateQuality::RequiresRepair, + RepairAction::VerifySlots(resync), + ) + }; + + AdapterEventResult::event( + AdapterEvent::new(pool.key.clone(), log.address, topic0, kind, quality) + .with_updates(updates) + .with_repair(repair), + ) } } @@ -714,3 +874,157 @@ fn topic_to_i32(topic: &B256) -> i32 { fn low_mask(bits: usize) -> U256 { (U256::from(1) << bits) - U256::from(1) } + +/// The low 128 bits of a 256-bit word (a `Tick.Info` word 0's `liquidityGross`). +fn u128_low(word: U256) -> u128 { + let limbs = word.as_limbs(); + (limbs[0] as u128) | ((limbs[1] as u128) << 64) +} + +/// The high 128 bits of a 256-bit word, as raw bits (word 0's `liquidityNet`, +/// two's-complement `int128`). +fn u128_high(word: U256) -> u128 { + let limbs = word.as_limbs(); + (limbs[2] as u128) | ((limbs[3] as u128) << 64) +} + +/// Pack `liquidityGross` (low 128) and `liquidityNet` (high 128, two's complement) +/// back into a `Tick.Info` word-0 value. +fn pack_gross_net(gross: u128, net: i128) -> U256 { + U256::from(gross) | (U256::from(net as u128) << 128) +} + +/// The `tickBitmap` bit index (0..256) for `tick`, matching the V3 +/// `TickBitmap.position` low byte (`compressed % 256`, floor-toward-negative). +/// `tick_spacing` must be positive (guaranteed by [`layout_for`]). +fn v3_bit_position(tick: i32, tick_spacing: i32) -> usize { + tick.div_euclid(tick_spacing).rem_euclid(256) as usize +} + +/// Apply a liquidity `amount` delta to a `Tick.Info` word 0, returning the new +/// packed word plus the tick's initialized state before/after. +/// +/// `liquidityGross` always moves by `+amount` (mint) / `-amount` (burn); +/// `liquidityNet` moves `+amount` for the lower tick and `-amount` for the upper +/// on a mint (negated on a burn) — captured by `add_to_net = is_mint == is_lower`. +/// Returns `None` on arithmetic overflow/underflow (invalid chain data) so the +/// caller can resync the tick instead of writing a wrong value. +fn apply_liquidity_delta( + word0: U256, + amount: u128, + is_mint: bool, + is_lower: bool, +) -> Option<(U256, bool, bool)> { + let old_gross = u128_low(word0); + let old_net = u128_high(word0) as i128; + let signed = i128::try_from(amount).ok()?; + + let new_gross = if is_mint { + old_gross.checked_add(amount)? + } else { + old_gross.checked_sub(amount)? + }; + let add_to_net = is_mint == is_lower; + let new_net = if add_to_net { + old_net.checked_add(signed)? + } else { + old_net.checked_sub(signed)? + }; + + let was_init = old_gross != 0; + let now_init = new_gross != 0; + Some((pack_gross_net(new_gross, new_net), was_init, now_init)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gross(word0: U256) -> u128 { + u128_low(word0) + } + fn net(word0: U256) -> i128 { + u128_high(word0) as i128 + } + + #[test] + fn pack_unpack_round_trips_including_negative_net() { + for (g, n) in [ + (0u128, 0i128), + (5, 7), + (u128::MAX, -1), + (123, i128::MIN), + (1, i128::MAX), + ] { + let w = pack_gross_net(g, n); + assert_eq!(gross(w), g); + assert_eq!(net(w), n); + } + } + + #[test] + fn mint_lower_adds_gross_and_net() { + // gross += amount (low), net += amount (high, lower tick). + let (w, was, now) = apply_liquidity_delta(pack_gross_net(10, 3), 4, true, true).unwrap(); + assert_eq!(gross(w), 14); + assert_eq!(net(w), 7); + assert!(was && now); + } + + #[test] + fn mint_upper_adds_gross_subtracts_net() { + let (w, _, _) = apply_liquidity_delta(pack_gross_net(10, 3), 4, true, false).unwrap(); + assert_eq!(gross(w), 14); + assert_eq!(net(w), -1); + } + + #[test] + fn burn_lower_subtracts_both() { + let (w, _, _) = apply_liquidity_delta(pack_gross_net(10, 3), 4, false, true).unwrap(); + assert_eq!(gross(w), 6); + assert_eq!(net(w), -1); + } + + #[test] + fn burn_upper_subtracts_gross_adds_net() { + let (w, _, _) = apply_liquidity_delta(pack_gross_net(10, 3), 4, false, false).unwrap(); + assert_eq!(gross(w), 6); + assert_eq!(net(w), 7); + } + + #[test] + fn mint_onto_empty_tick_reports_initialization() { + // A tick with zero gross that gains liquidity flips uninitialized→initialized. + let (w, was, now) = apply_liquidity_delta(U256::ZERO, 5, true, true).unwrap(); + assert_eq!(gross(w), 5); + assert_eq!(net(w), 5); + assert!(!was && now); + } + + #[test] + fn burn_to_zero_reports_clear_and_zeroes_word() { + // Burning all of a tick's gross flips initialized→uninitialized; the lower + // tick's net returns to zero, so word 0 is fully zero. + let (w, was, now) = apply_liquidity_delta(pack_gross_net(5, 5), 5, false, true).unwrap(); + assert_eq!(w, U256::ZERO); + assert!(was && !now); + } + + #[test] + fn burn_more_than_gross_is_rejected() { + assert!(apply_liquidity_delta(pack_gross_net(3, 3), 4, false, true).is_none()); + } + + #[test] + fn bit_position_matches_uniswap_position_low_byte() { + // spacing 1: compressed == tick; bit = tick mod 256 (floor for negatives). + assert_eq!(v3_bit_position(0, 1), 0); + assert_eq!(v3_bit_position(255, 1), 255); + assert_eq!(v3_bit_position(256, 1), 0); + assert_eq!(v3_bit_position(-1, 1), 255); // word -1, top bit + assert_eq!(v3_bit_position(-256, 1), 0); + // spacing 60: compressed = tick/60. + assert_eq!(v3_bit_position(60, 60), 1); + assert_eq!(v3_bit_position(120, 60), 2); + } +} diff --git a/tests/adapter_reactive.rs b/tests/adapter_reactive.rs index 6c8f59e..ed99cb8 100644 --- a/tests/adapter_reactive.rs +++ b/tests/adapter_reactive.rs @@ -221,28 +221,24 @@ fn word_pos(tick: i32, tick_spacing: i32) -> i16 { tick.div_euclid(tick_spacing).div_euclid(256) as i16 } -/// Independent oracle for the slot set a V3 liquidity-event repair must resync: -/// all four boundary `Tick.Info` slots, the (deduped) containing bitmap words, -/// and the global liquidity slot. Returned sorted and deduped. -fn expected_tick_repair_slots( +/// Oracle for the slot set a liquidity event resyncs when nothing is warmed +/// (`slot0` and both boundary ticks cold): all four `Tick.Info` slots of each +/// tick, the (deduped) containing bitmap words, and the global `liquidity` slot +/// (with `slot0` cold the in-range decision is unknown, so `liquidity` is +/// conservatively resynced rather than dropped). Sorted and deduped. +fn expected_cold_resync_slots( layout: V3StorageLayout, tick_lower: i32, tick_upper: i32, ) -> Vec { let mut slots = Vec::new(); for tick in [tick_lower, tick_upper] { - let keys = v3_tick_info_storage_keys_with_base(tick, layout.ticks_base_slot); - slots.extend_from_slice(&keys); - } - let mut words = vec![ - word_pos(tick_lower, layout.tick_spacing), - word_pos(tick_upper, layout.tick_spacing), - ]; - words.sort_unstable(); - words.dedup(); - for word in words { + slots.extend_from_slice(&v3_tick_info_storage_keys_with_base( + tick, + layout.ticks_base_slot, + )); slots.push(v3_tick_bitmap_storage_key_with_base( - word, + word_pos(tick, layout.tick_spacing), layout.tick_bitmap_base_slot, )); } @@ -252,6 +248,12 @@ fn expected_tick_repair_slots( slots } +/// 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) +} + fn v3_mint_log(pool: Address, tick_lower: i32, tick_upper: i32, block_number: u64) -> RpcLog { let mut data = address_word(Address::repeat_byte(0x03)); data.extend(abi_words([ @@ -590,30 +592,15 @@ async fn v3_swap_applies_slot0_and_liquidity_updates_through_runtime() -> Result Ok(()) } +// A Mint whose boundary ticks are cold (nothing warmed) cannot be +// read-modify-written, so the adapter emits a targeted resync over exactly those +// ticks' info + bitmap slots (no direct writes, no whole-storage invalidation). #[tokio::test] -async fn v3_mint_emits_tick_range_repair_hook() -> Result<()> { +async fn v3_mint_cold_ticks_emit_resync() -> Result<()> { let pool = Address::repeat_byte(0x42); + let layout = V3StorageLayout::uniswap(60); let tick_lower = 60; let tick_upper = 120; - let mut data = address_word(Address::repeat_byte(0x03)); - data.extend(abi_words([ - U256::from(1_u64), - U256::from(2_u64), - U256::from(3_u64), - ])); - let log = rpc_log( - pool, - vec![ - v3_mint_topic(), - topic_address(Address::repeat_byte(0x04)), - topic_i24(tick_lower), - topic_i24(tick_upper), - ], - data, - 14, - 0, - 0, - ); let mut cache = setup_cache().await?; let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); @@ -621,7 +608,10 @@ async fn v3_mint_emits_tick_range_repair_hook() -> Result<()> { let report = runtime.ingest_batch( &mut cache, - batch(vec![(ReactiveInput::Log(log), included_context(14, 0))]), + batch(vec![( + ReactiveInput::Log(v3_mint_log(pool, tick_lower, tick_upper, 14)), + included_context(14, 0), + )]), )?; assert_eq!(report.applied.len(), 1); @@ -629,17 +619,146 @@ async fn v3_mint_emits_tick_range_repair_hook() -> Result<()> { report.applied[0].quality, StateEffectQuality::RequiresRepair ); + // Nothing warm to write; the whole effect is the resync. assert!(report.applied[0].state_updates.is_empty()); - let repair_signal = report.applied[0] - .hook_signals - .iter() - .find(|signal| { - signal.namespace.as_ref() == "evm-amm-state" - && signal.kind.as_ref() == "amm.repair.v3_tick_range" - }) - .expect("V3 liquidity changes should emit a tick-range repair hook"); - assert_eq!(tag_value(&repair_signal.labels, "tick_lower"), Some("60")); - assert_eq!(tag_value(&repair_signal.labels, "tick_upper"), Some("120")); + assert_eq!(report.applied[0].resyncs.len(), 1); + let [ResyncTarget::StorageSlots { address, slots }] = + report.applied[0].resyncs[0].targets.as_slice() + else { + panic!("expected a single storage-slots resync target"); + }; + assert_eq!(*address, pool); + let mut got = slots.clone(); + got.sort_unstable(); + got.dedup(); + assert_eq!( + got, + expected_cold_resync_slots(layout, tick_lower, tick_upper) + ); + // The observability event hook is still emitted. + assert!( + report.applied[0] + .hook_signals + .iter() + .any(|signal| signal.kind.as_ref() == "amm.event") + ); + Ok(()) +} + +// A Mint whose ticks and global liquidity are already warmed applies exact +// direct writes with NO resync: gross/net on each boundary tick's packed word 0, +// and the in-range global liquidity — the event-sourced (no-RPC) hot path. +#[tokio::test] +async fn v3_mint_warm_applies_direct_writes() -> Result<()> { + let pool = Address::repeat_byte(0x4a); + let layout = V3StorageLayout::uniswap(60); + let tick_lower = 60; + let tick_upper = 180; // current tick 120 is in [60, 180) + let amount = 7u128; // v3_mint_log bakes amount = 7 + + 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 mut cache = setup_cache().await?; + // Warm slot0 (current tick 120, in range), global liquidity, and both already + // initialized boundary ticks. + cache.apply_updates(&[ + StateUpdate::slot( + pool, + layout.slot0_slot, + v3_slot0_word(U256::from(1u64), 120, U256::ZERO), + ), + StateUpdate::slot(pool, layout.liquidity_slot, U256::from(1_000u64)), + StateUpdate::slot(pool, lower_key, packed_tick_word0(100, 40)), + StateUpdate::slot(pool, upper_key, packed_tick_word0(100, -40)), + ]); + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(v3_registry(pool))))?; + + let report = runtime.ingest_batch( + &mut cache, + batch(vec![( + ReactiveInput::Log(v3_mint_log(pool, tick_lower, tick_upper, 24)), + included_context(24, 0), + )]), + )?; + + assert_eq!(report.applied.len(), 1); + assert_eq!( + report.applied[0].quality, + StateEffectQuality::ExactFromInput + ); + assert!( + report.applied[0].resyncs.is_empty(), + "fully-warm liquidity event must not resync" + ); + + // Global in-range liquidity += amount. + assert_eq!( + cache.cached_storage_value(pool, layout.liquidity_slot), + Some(U256::from(1_000u64 + amount as u64)) + ); + // Lower tick: gross += amount, net += amount. + assert_eq!( + cache.cached_storage_value(pool, lower_key), + Some(packed_tick_word0(100 + amount, 40 + amount as i128)) + ); + // Upper tick: gross += amount, net -= amount. + assert_eq!( + cache.cached_storage_value(pool, upper_key), + Some(packed_tick_word0(100 + amount, -40 - amount as i128)) + ); + Ok(()) +} + +// A Mint whose range does NOT straddle the current tick leaves global liquidity +// untouched, while still updating the boundary ticks' gross/net. +#[tokio::test] +async fn v3_mint_out_of_range_leaves_global_liquidity() -> Result<()> { + let pool = Address::repeat_byte(0x4b); + let layout = V3StorageLayout::uniswap(60); + let tick_lower = 60; + let tick_upper = 180; // current tick 600 is ABOVE the range + let amount = 7u128; + let lower_key = v3_tick_info_storage_keys_with_base(tick_lower, layout.ticks_base_slot)[0]; + + let mut cache = setup_cache().await?; + cache.apply_updates(&[ + StateUpdate::slot( + pool, + layout.slot0_slot, + v3_slot0_word(U256::from(1u64), 600, U256::ZERO), + ), + StateUpdate::slot(pool, layout.liquidity_slot, U256::from(1_000u64)), + StateUpdate::slot(pool, lower_key, packed_tick_word0(100, 40)), + StateUpdate::slot( + pool, + v3_tick_info_storage_keys_with_base(tick_upper, layout.ticks_base_slot)[0], + packed_tick_word0(100, -40), + ), + ]); + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(v3_registry(pool))))?; + + runtime.ingest_batch( + &mut cache, + batch(vec![( + ReactiveInput::Log(v3_mint_log(pool, tick_lower, tick_upper, 25)), + included_context(25, 0), + )]), + )?; + + // Out of range: global liquidity unchanged; boundary tick still updated. + assert_eq!( + cache.cached_storage_value(pool, layout.liquidity_slot), + Some(U256::from(1_000u64)) + ); + assert_eq!( + cache.cached_storage_value(pool, lower_key), + Some(packed_tick_word0(100 + amount, 40 + amount as i128)) + ); Ok(()) } @@ -750,6 +869,194 @@ async fn removed_log_rolls_back_previously_applied_update() -> Result<()> { Ok(()) } +// A Burn that removes a warm tick's entire gross liquidity clears its bitmap bit +// (Uniswap `flipTick` XOR) and zeroes its word 0 — all event-sourced, no resync. +#[tokio::test] +async fn v3_burn_to_zero_clears_bitmap_bit() -> Result<()> { + let pool = Address::repeat_byte(0x4c); + let layout = V3StorageLayout::uniswap(60); + let tick_lower = 60; // bit 1 + let tick_upper = 180; // bit 3 (same bitmap word 0) + let amount = 7u128; // v3_burn_log bakes amount = 7 + + 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 bitmap_key = v3_tick_bitmap_storage_key_with_base( + word_pos(tick_lower, layout.tick_spacing), + layout.tick_bitmap_base_slot, + ); + let bits_set = (U256::from(1u8) << 1) | (U256::from(1u8) << 3); + + let mut cache = setup_cache().await?; + cache.apply_updates(&[ + StateUpdate::slot( + pool, + layout.slot0_slot, + v3_slot0_word(U256::from(1u64), 120, U256::ZERO), + ), + StateUpdate::slot(pool, layout.liquidity_slot, U256::from(1_000u64)), + // Each boundary tick holds exactly `amount` gross, so a full burn zeroes it. + StateUpdate::slot(pool, lower_key, packed_tick_word0(amount, amount as i128)), + StateUpdate::slot( + pool, + upper_key, + packed_tick_word0(amount, -(amount as i128)), + ), + StateUpdate::slot(pool, bitmap_key, bits_set), + ]); + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(v3_registry(pool))))?; + + let report = runtime.ingest_batch( + &mut cache, + batch(vec![( + ReactiveInput::Log(v3_burn_log(pool, tick_lower, tick_upper, 26)), + included_context(26, 0), + )]), + )?; + + assert!(report.applied[0].resyncs.is_empty()); + // Both ticks fully burned -> word 0 zeroed, both bitmap bits cleared (XOR). + assert_eq!( + cache.cached_storage_value(pool, lower_key), + Some(U256::ZERO) + ); + assert_eq!( + cache.cached_storage_value(pool, upper_key), + Some(U256::ZERO) + ); + assert_eq!( + cache.cached_storage_value(pool, bitmap_key), + Some(U256::ZERO) + ); + // In-range liquidity decreased by amount. + assert_eq!( + cache.cached_storage_value(pool, layout.liquidity_slot), + Some(U256::from(1_000u64 - amount as u64)) + ); + Ok(()) +} + +// One warm boundary tick + one cold: the warm tick gets a direct write, the cold +// tick is resynced (its info + bitmap slots) — the mixed hybrid path. +#[tokio::test] +async fn v3_mint_mixed_warm_and_cold_ticks() -> Result<()> { + let pool = Address::repeat_byte(0x4d); + let layout = V3StorageLayout::uniswap(60); + let tick_lower = 60; // warm + let tick_upper = 15_360; // cold, in a different bitmap word + let amount = 7u128; + let lower_key = v3_tick_info_storage_keys_with_base(tick_lower, layout.ticks_base_slot)[0]; + + let mut cache = setup_cache().await?; + // Warm slot0 (current tick 120, in range), liquidity, and ONLY the lower tick. + cache.apply_updates(&[ + StateUpdate::slot( + pool, + layout.slot0_slot, + v3_slot0_word(U256::from(1u64), 120, U256::ZERO), + ), + StateUpdate::slot(pool, layout.liquidity_slot, U256::from(1_000u64)), + StateUpdate::slot(pool, lower_key, packed_tick_word0(100, 40)), + ]); + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(v3_registry(pool))))?; + + let report = runtime.ingest_batch( + &mut cache, + batch(vec![( + ReactiveInput::Log(v3_mint_log(pool, tick_lower, tick_upper, 27)), + included_context(27, 0), + )]), + )?; + + assert_eq!( + report.applied[0].quality, + StateEffectQuality::RequiresRepair + ); + // Warm lower tick + in-range liquidity: exact direct writes. + assert_eq!( + cache.cached_storage_value(pool, lower_key), + Some(packed_tick_word0(100 + amount, 40 + amount as i128)) + ); + assert_eq!( + cache.cached_storage_value(pool, layout.liquidity_slot), + Some(U256::from(1_000u64 + amount as u64)) + ); + // Cold upper tick: exactly its info + bitmap slots resynced (nothing else). + let [ResyncTarget::StorageSlots { slots, .. }] = + report.applied[0].resyncs[0].targets.as_slice() + else { + panic!("expected a storage-slots resync target"); + }; + let mut got = slots.clone(); + got.sort_unstable(); + got.dedup(); + let mut expected = + v3_tick_info_storage_keys_with_base(tick_upper, layout.ticks_base_slot).to_vec(); + expected.push(v3_tick_bitmap_storage_key_with_base( + word_pos(tick_upper, layout.tick_spacing), + layout.tick_bitmap_base_slot, + )); + expected.sort_unstable(); + expected.dedup(); + assert_eq!(got, expected); + Ok(()) +} + +// A degraded pool with cold slot0 but warm ticks: the boundary ticks still get +// direct writes, and the global liquidity is conservatively RESYNCED (not +// silently dropped) since the in-range decision can't be made. +#[tokio::test] +async fn v3_mint_cold_slot0_resyncs_global_liquidity() -> Result<()> { + let pool = Address::repeat_byte(0x4e); + let layout = V3StorageLayout::uniswap(60); + let tick_lower = 60; + let tick_upper = 180; + let amount = 7u128; + 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 mut cache = setup_cache().await?; + // Warm both ticks (no flip: gross stays nonzero) but NOT slot0 or liquidity. + cache.apply_updates(&[ + StateUpdate::slot(pool, lower_key, packed_tick_word0(100, 40)), + StateUpdate::slot(pool, upper_key, packed_tick_word0(100, -40)), + ]); + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(AmmReactiveHandler::new(v3_registry(pool))))?; + + let report = runtime.ingest_batch( + &mut cache, + batch(vec![( + ReactiveInput::Log(v3_mint_log(pool, tick_lower, tick_upper, 28)), + included_context(28, 0), + )]), + )?; + + // Warm ticks: direct writes. + assert_eq!( + cache.cached_storage_value(pool, lower_key), + Some(packed_tick_word0(100 + amount, 40 + amount as i128)) + ); + // The global liquidity delta is not dropped: it is resynced (only that slot; + // ticks are warm and don't flip, so they need no resync). + assert_eq!( + report.applied[0].quality, + StateEffectQuality::RequiresRepair + ); + let [ResyncTarget::StorageSlots { slots, .. }] = + report.applied[0].resyncs[0].targets.as_slice() + else { + panic!("expected a storage-slots resync target"); + }; + assert_eq!(slots.as_slice(), &[layout.liquidity_slot]); + Ok(()) +} + #[tokio::test] async fn v3_mint_emits_targeted_tick_resync() -> Result<()> { let pool = Address::repeat_byte(0x43); @@ -798,28 +1105,20 @@ async fn v3_mint_emits_targeted_tick_resync() -> Result<()> { got.dedup(); assert_eq!( got, - expected_tick_repair_slots(layout, tick_lower, tick_upper) - ); - - // Observability hook is preserved alongside the executable resync. - assert!( - report.applied[0] - .hook_signals - .iter() - .any(|signal| signal.kind.as_ref() == "amm.repair.v3_tick_range") + expected_cold_resync_slots(layout, tick_lower, tick_upper) ); Ok(()) } #[tokio::test] -async fn v3_mint_resync_repairs_tick_and_liquidity_slots() -> Result<()> { +async fn v3_mint_resync_repairs_cold_tick_slots() -> Result<()> { let pool = Address::repeat_byte(0x44); let layout = V3StorageLayout::uniswap(60); let tick_lower = 60; let tick_upper = 180; let block = 21; - let expected = expected_tick_repair_slots(layout, tick_lower, tick_upper); + let expected = expected_cold_resync_slots(layout, tick_lower, tick_upper); let mut fetched: HashMap<(Address, U256), U256> = HashMap::new(); for (i, slot) in expected.iter().enumerate() { fetched.insert((pool, *slot), U256::from(1_000 + i as u64)); @@ -896,10 +1195,11 @@ async fn v3_burn_same_word_dedupes_bitmap_slot() -> Result<()> { let mut got = slots.clone(); got.sort_unstable(); got.dedup(); - // 2 ticks x 4 info words + 1 shared bitmap word + liquidity = 10 slots. + // Cold everything, shared bitmap word: 2 ticks x 4 info words + 1 shared + // bitmap word + global liquidity (slot0 cold -> conservatively resynced) = 10. assert_eq!( got, - expected_tick_repair_slots(layout, tick_lower, tick_upper) + expected_cold_resync_slots(layout, tick_lower, tick_upper) ); assert_eq!(got.len(), 10); Ok(()) @@ -1684,14 +1984,14 @@ async fn pancake_v3_mint_repair_targets_pancake_layout_slots() -> Result<()> { got.dedup(); assert_eq!( got, - expected_tick_repair_slots(layout, tick_lower, tick_upper), + expected_cold_resync_slots(layout, tick_lower, tick_upper), "repair must target the Pancake layout slots" ); // The Pancake slots are genuinely distinct from the Uniswap layout's, // proving the family adapter lowered the repair against the Pancake layout. assert_ne!( got, - expected_tick_repair_slots(V3StorageLayout::uniswap(60), tick_lower, tick_upper) + expected_cold_resync_slots(V3StorageLayout::uniswap(60), tick_lower, tick_upper) ); Ok(()) } @@ -1738,12 +2038,12 @@ async fn slipstream_mint_repair_targets_slipstream_layout_slots() -> Result<()> got.dedup(); assert_eq!( got, - expected_tick_repair_slots(layout, tick_lower, tick_upper), + expected_cold_resync_slots(layout, tick_lower, tick_upper), "repair must target the Slipstream layout slots" ); assert_ne!( got, - expected_tick_repair_slots(V3StorageLayout::uniswap(60), tick_lower, tick_upper) + expected_cold_resync_slots(V3StorageLayout::uniswap(60), tick_lower, tick_upper) ); Ok(()) } From 1daeb8256135818a703651ec30980439c06bcd09 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 7 Jul 2026 14:57:51 +0100 Subject: [PATCH 2/2] test(uniswap-v3): live RPC parity for Mint/Burn event-sourcing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests/v3_liquidity_rpc.rs (env-gated, #[ignore]): for a real add- and remove-liquidity transaction, fetch the exact per-tx storage diff via trace_replayTransaction(stateDiff), warm the pre-tx state, apply the event through the adapter, and assert the event-sourced writes reproduce the on-chain post-tx values for every slot the adapter maintains — each boundary tick's packed Tick.Info word 0 (liquidityGross/liquidityNet, incl. a negative net in two's complement) and the in-range global liquidity. Verified live against a mainnet archive+trace endpoint: a JIT add + remove of the same liquidity on the USDC/WETH 0.05% pool — BOTH the Mint and the Burn reproduce the on-chain per-tx storage exactly (tickLower/tickUpper word0 + global liquidity). This is the RPC parity check deferred in the feature commit; it now exists and passes. serde_json added as a dev-dependency (parses the trace stateDiff JSON). Full gate green: clippy -D warnings (all-features + no-default), offline tests, fmt, doc, missing_docs=0. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 + Cargo.toml | 10 ++ tests/v3_liquidity_rpc.rs | 248 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 tests/v3_liquidity_rpc.rs diff --git a/Cargo.lock b/Cargo.lock index 13f26ef..1cc1a7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1903,6 +1903,7 @@ dependencies = [ "futures", "reqwest", "revm", + "serde_json", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 865e02d..47aaedf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. @@ -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"] diff --git a/tests/v3_liquidity_rpc.rs b/tests/v3_liquidity_rpc.rs new file mode 100644 index 0000000..b88dcff --- /dev/null +++ b/tests/v3_liquidity_rpc.rs @@ -0,0 +1,248 @@ +//! Live RPC parity for **event-sourced** Uniswap V3 `Mint`/`Burn` (env-gated, +//! `#[ignore]`). +//! +//! For a real add-liquidity (`Mint`) and remove-liquidity (`Burn`) transaction, +//! this fetches the transaction's *exact per-tx* storage diff via +//! `trace_replayTransaction(stateDiff)` — the on-chain ground truth, immune to +//! other transactions in the same block — warms the pre-tx (`from`) values into a +//! cache, applies the event through the adapter, and asserts the adapter's +//! event-sourced writes reproduce the on-chain post-tx (`to`) values for every +//! slot the adapter maintains (each boundary tick's packed `Tick.Info` word 0 and +//! the in-range global `liquidity`). +//! +//! The pinned pair is a just-in-time (JIT) add + remove of the same liquidity on +//! the same in-range, already-initialized ticks of the USDC/WETH 0.05% pool, so +//! it exercises `liquidityGross`/`liquidityNet` (including a negative net in +//! two's complement) and the in-range liquidity delta against real state. +//! +//! Run: `E2E_RPC_URL= cargo test --test v3_liquidity_rpc -- --ignored --nocapture` + +use std::collections::HashMap; +use std::sync::Arc; + +use alloy_eips::BlockId; +use alloy_primitives::{Address, B256, U256, address}; +use alloy_provider::{Provider, RootProvider, network::AnyNetwork}; +use alloy_rpc_types_eth::Filter; +use anyhow::Result; +use evm_amm_state::adapters::driver::AdapterDriver; +use evm_amm_state::adapters::storage::{ + V3StorageLayout, v3_tick_bitmap_storage_key_with_base, v3_tick_info_storage_keys_with_base, + v3_word_position, +}; +use evm_amm_state::adapters::{ + AdapterCache, AdapterRegistry, AmmAdapter, ConcentratedLiquidityAdapter, PoolKey, + PoolRegistration, ProtocolMetadata, StateUpdate, V3Metadata, +}; +use evm_fork_cache::cache::EvmCache; + +const POOL: Address = address!("88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640"); +const TICK_SPACING: i32 = 10; +const TICK_LOWER: i32 = 201_470; +const TICK_UPPER: i32 = 201_480; +const EVENT_BLOCK: u64 = 0x184c2a1; // block containing both the mint and the burn + +// A JIT add (Mint) and remove (Burn) of the same liquidity on [201470, 201480]. +const MINT_TX: &str = "0xdfd6176e2c22e1acad7d7ebfff14541c2c5ed0c31ec06b4812391c200bbac5a5"; +const BURN_TX: &str = "0x9d230517d95e119b6a4b5c300c1184455018074ab361df3368330358cdb88e4c"; + +fn mint_topic() -> B256 { + alloy_primitives::keccak256("Mint(address,address,int24,int24,uint128,uint256,uint256)") +} +fn burn_topic() -> B256 { + alloy_primitives::keccak256("Burn(address,int24,int24,uint128,uint256,uint256)") +} + +fn parse_u256(hex: &str) -> U256 { + U256::from_str_radix(hex.trim_start_matches("0x"), 16).unwrap_or(U256::ZERO) +} + +/// Fetch the per-tx storage diff for `POOL`: slot -> (from, to). +async fn pool_state_diff( + provider: &RootProvider, + tx: &str, +) -> Result> { + let value: serde_json::Value = provider + .raw_request( + "trace_replayTransaction".into(), + (tx.to_string(), vec!["stateDiff".to_string()]), + ) + .await?; + + let pool_lc = format!("{POOL:?}").to_lowercase(); + let mut diff = HashMap::new(); + let Some(accounts) = value["stateDiff"].as_object() else { + anyhow::bail!("trace_replayTransaction returned no stateDiff (trace unsupported?)"); + }; + for (addr, account) in accounts { + if addr.to_lowercase() != pool_lc { + continue; + } + if let Some(storage) = account["storage"].as_object() { + for (slot_hex, change) in storage { + let slot = parse_u256(slot_hex); + if let Some(star) = change.get("*") { + diff.insert( + slot, + ( + parse_u256(star["from"].as_str().unwrap_or("0x0")), + parse_u256(star["to"].as_str().unwrap_or("0x0")), + ), + ); + } else if let Some(plus) = change.get("+") { + diff.insert( + slot, + (U256::ZERO, parse_u256(plus.as_str().unwrap_or("0x0"))), + ); + } + } + } + } + Ok(diff) +} + +async fn run_parity(is_mint: bool) -> Result<()> { + let Ok(url) = std::env::var("E2E_RPC_URL") else { + eprintln!("E2E_RPC_URL unset — skipping V3 liquidity RPC parity test."); + return Ok(()); + }; + let (tx, topic0) = if is_mint { + (MINT_TX, mint_topic()) + } else { + (BURN_TX, burn_topic()) + }; + + let provider = Arc::new(RootProvider::::connect(&url).await?); + let diff = pool_state_diff(&provider, tx).await?; + + let layout = V3StorageLayout::uniswap(TICK_SPACING); + let lower_w0 = v3_tick_info_storage_keys_with_base(TICK_LOWER, layout.ticks_base_slot)[0]; + let upper_w0 = v3_tick_info_storage_keys_with_base(TICK_UPPER, layout.ticks_base_slot)[0]; + let bitmap = v3_tick_bitmap_storage_key_with_base( + v3_word_position(TICK_LOWER, TICK_SPACING), + layout.tick_bitmap_base_slot, + ); + + // The adapter maintains exactly these on a warm in-range liquidity event. + let asserted = [ + ("tickLower.word0", lower_w0), + ("tickUpper.word0", upper_w0), + ("global liquidity", layout.liquidity_slot), + ]; + // Every asserted slot must actually be one the tx changed (else the test is + // vacuous / mis-mapped). + for (name, slot) in asserted { + assert!( + diff.contains_key(&slot), + "{name} ({slot:#x}) not in the tx storage diff — mapping/pin is wrong" + ); + } + // These ticks are already initialized (other LPs hold them), so this event + // does not flip the bitmap — and the adapter must not touch it. + assert!( + !diff.contains_key(&bitmap), + "unexpected bitmap change: pinned event should not (de)initialize a tick" + ); + + // Warm the slots the decode reads. Written slots (the tick word0s and, for an + // in-range event, global liquidity) use the exact per-tx `from`. Read-only + // slots — `slot0` (a burn reads it to decide in-range but does not write it) + // and the unchanged `bitmap` — are absent from the stateDiff, so fall back to + // their pre-block chain value. + let mut cache = EvmCache::at_block(provider.clone(), BlockId::number(EVENT_BLOCK - 1)).await; + let pre = |slot: U256| diff.get(&slot).map(|(from, _)| *from); + let pre_block = BlockId::number(EVENT_BLOCK - 1); + let slot0_pre = match pre(layout.slot0_slot) { + Some(from) => from, + None => { + provider + .get_storage_at(POOL, layout.slot0_slot) + .block_id(pre_block) + .await? + } + }; + let bitmap_pre = provider + .get_storage_at(POOL, bitmap) + .block_id(pre_block) + .await?; + AdapterCache::apply_updates( + &mut cache, + &[ + StateUpdate::slot(POOL, layout.slot0_slot, slot0_pre), + StateUpdate::slot( + POOL, + layout.liquidity_slot, + pre(layout.liquidity_slot).unwrap(), + ), + StateUpdate::slot(POOL, lower_w0, pre(lower_w0).unwrap()), + StateUpdate::slot(POOL, upper_w0, pre(upper_w0).unwrap()), + StateUpdate::slot(POOL, bitmap, bitmap_pre), + ], + ); + + // Register the pool and apply the real event through the adapter. + let adapter = Arc::new(ConcentratedLiquidityAdapter::default()); + let mut registration = PoolRegistration::new(PoolKey::UniswapV3(POOL)) + .with_state_address(POOL) + .with_metadata(ProtocolMetadata::UniswapV3( + V3Metadata::default() + .with_storage_layout(layout) + .with_tick_spacing(TICK_SPACING), + )); + let sources = adapter.event_sources(®istration); + registration = registration.with_event_sources(sources); + + let mut registry = AdapterRegistry::new(); + registry.register_adapter(adapter).unwrap(); + registry.register_pool(registration).unwrap(); + let driver = AdapterDriver::new(registry); + + // Fetch the real event log from the chain (its exact topics + data) rather + // than reconstructing it, so the test is immune to hand-copied hex errors. + let tx_hash: B256 = tx.parse()?; + let filter = Filter::new() + .address(POOL) + .event_signature(topic0) + .from_block(EVENT_BLOCK) + .to_block(EVENT_BLOCK); + let log = provider + .get_logs(&filter) + .await? + .into_iter() + .find(|entry| entry.transaction_hash == Some(tx_hash)) + .expect("event log not found in block") + .inner; + + driver + .apply_log(&mut cache, &log)? + .expect("event must route and apply"); + + // The adapter's event-sourced writes must equal the on-chain post-tx values. + for (name, slot) in asserted { + let (_from, to) = diff[&slot]; + assert_eq!( + cache.cached_storage_value(POOL, slot), + Some(to), + "{name} ({slot:#x}): event-sourced value != on-chain post-tx value" + ); + eprintln!( + "{} {name}: matches on-chain {to:#x}", + if is_mint { "MINT" } else { "BURN" } + ); + } + // The bitmap was not flipped, so it is left exactly as warmed. + assert_eq!(cache.cached_storage_value(POOL, bitmap), Some(bitmap_pre)); + Ok(()) +} + +#[tokio::test] +#[ignore = "requires an archive+trace RPC via E2E_RPC_URL; run with --ignored"] +async fn mint_event_sourcing_matches_onchain_state_diff() -> Result<()> { + run_parity(true).await +} + +#[tokio::test] +#[ignore = "requires an archive+trace RPC via E2E_RPC_URL; run with --ignored"] +async fn burn_event_sourcing_matches_onchain_state_diff() -> Result<()> { + run_parity(false).await +}