Skip to content

Commit 692410c

Browse files
committed
Fix DeFi replay crossed-tick drift
- Skip simulated tick-cross mutations after event-proven replay drift - Keep swap fee accounting while anchoring tick, liquidity, and sqrt - Add regression coverage for crossed-tick and fee-state invariants
1 parent cabedee commit 692410c

2 files changed

Lines changed: 148 additions & 14 deletions

File tree

crates/model/src/defi/pool_analysis/profiler.rs

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -315,36 +315,58 @@ impl PoolProfiler {
315315
.simulate_swap_through_ticks(amount_specified, zero_for_one, sqrt_price_limit_x96, true)
316316
.map_err(|e| Self::wrap_liquidity_error(e, location))?;
317317

318-
self.apply_swap_quote(&swap_quote);
318+
let tick_mismatch = swap.tick != swap_quote.tick_after;
319+
let liquidity_mismatch = swap.liquidity != swap_quote.liquidity_after;
320+
let sqrt_mismatch = swap.sqrt_price_x96 != swap_quote.sqrt_price_after_x96;
321+
let structural_mismatch = tick_mismatch || liquidity_mismatch;
322+
if structural_mismatch && !swap_quote.crossed_ticks.is_empty() {
323+
log::warn!(
324+
"Replay swap simulation diverged after crossing {} ticks on block {}; anchoring event state without simulated tick-cross mutations",
325+
swap_quote.crossed_ticks.len(),
326+
swap.block
327+
);
328+
self.apply_swap_quote_without_crossed_ticks(&swap_quote);
329+
} else {
330+
self.apply_swap_quote(&swap_quote);
331+
}
319332

320333
// Verify simulation against event data - correct with event values if mismatch detected
321-
if swap.tick != self.state.current_tick {
334+
if tick_mismatch {
322335
log::warn!(
323336
"Inconsistency in swap processing: Current tick mismatch: simulated {}, event {} on block {}",
324-
self.state.current_tick,
337+
swap_quote.tick_after,
325338
swap.tick,
326339
swap.block
327340
);
341+
}
342+
343+
if swap.tick != self.state.current_tick {
328344
self.state.current_tick = swap.tick;
329345
}
330346

331-
if swap.liquidity != self.tick_map.liquidity {
347+
if liquidity_mismatch {
332348
log::warn!(
333349
"Inconsistency in swap processing: Active liquidity mismatch: simulated {}, event {} on block {}",
334-
self.tick_map.liquidity,
350+
swap_quote.liquidity_after,
335351
swap.liquidity,
336352
swap.block
337353
);
354+
}
355+
356+
if swap.liquidity != self.tick_map.liquidity {
338357
self.tick_map.liquidity = swap.liquidity;
339358
}
340359

341-
if swap.sqrt_price_x96 != self.state.price_sqrt_ratio_x96 {
360+
if sqrt_mismatch {
342361
log::warn!(
343362
"Inconsistency in swap processing: Sqrt price mismatch: simulated {}, event {} on block {}",
344-
self.state.price_sqrt_ratio_x96,
363+
swap_quote.sqrt_price_after_x96,
345364
swap.sqrt_price_x96,
346365
swap.block
347366
);
367+
}
368+
369+
if swap.sqrt_price_x96 != self.state.price_sqrt_ratio_x96 {
348370
self.state.price_sqrt_ratio_x96 = swap.sqrt_price_x96;
349371
}
350372

@@ -641,13 +663,7 @@ impl PoolProfiler {
641663
self.state.current_tick = swap_quote.tick_after;
642664
self.state.price_sqrt_ratio_x96 = swap_quote.sqrt_price_after_x96;
643665

644-
if swap_quote.zero_for_one() {
645-
self.state.fee_growth_global_0 = swap_quote.fee_growth_global_after;
646-
self.state.protocol_fees_token0 += swap_quote.protocol_fee;
647-
} else {
648-
self.state.fee_growth_global_1 = swap_quote.fee_growth_global_after;
649-
self.state.protocol_fees_token1 += swap_quote.protocol_fee;
650-
}
666+
self.apply_swap_quote_fee_state(swap_quote);
651667

652668
for crossed in &swap_quote.crossed_ticks {
653669
let liquidity_net =
@@ -669,6 +685,23 @@ impl PoolProfiler {
669685
);
670686
}
671687

688+
fn apply_swap_quote_without_crossed_ticks(&mut self, swap_quote: &SwapQuote) {
689+
self.state.current_tick = swap_quote.tick_after;
690+
self.state.price_sqrt_ratio_x96 = swap_quote.sqrt_price_after_x96;
691+
self.apply_swap_quote_fee_state(swap_quote);
692+
self.analytics.total_swaps += 1;
693+
}
694+
695+
fn apply_swap_quote_fee_state(&mut self, swap_quote: &SwapQuote) {
696+
if swap_quote.zero_for_one() {
697+
self.state.fee_growth_global_0 = swap_quote.fee_growth_global_after;
698+
self.state.protocol_fees_token0 += swap_quote.protocol_fee;
699+
} else {
700+
self.state.fee_growth_global_1 = swap_quote.fee_growth_global_after;
701+
self.state.protocol_fees_token1 += swap_quote.protocol_fee;
702+
}
703+
}
704+
672705
/// Wraps a low-level [`LiquidityMathError`](super::error::LiquidityMathError) into a
673706
/// [`PoolProfilerError`] carrying the supplied event location, leaving non-liquidity
674707
/// errors untouched.

crates/model/src/defi/pool_analysis/tests.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,107 @@ fn test_process_swap_snaps_sqrt_price_to_event() {
828828
assert_eq!(profiler.state.price_sqrt_ratio_x96, event_sqrt_price);
829829
}
830830

831+
#[rstest]
832+
fn test_process_swap_mismatch_does_not_mutate_simulated_crossed_tick() {
833+
let mut actual_profiler = uni_pool_profiler();
834+
actual_profiler
835+
.process(&DexPoolData::FeeProtocolUpdate(create_fee_protocol_update(
836+
6, 0,
837+
)))
838+
.unwrap();
839+
840+
let mut drifted_profiler = actual_profiler.clone();
841+
let pool_identifier = actual_profiler.pool.pool_identifier;
842+
let stale_upper_tick = -23040;
843+
let stale_lower_tick = PoolTick::get_min_tick(TICK_SPACING);
844+
845+
drifted_profiler
846+
.process(&DexPoolData::LiquidityUpdate(create_mint_event(
847+
lp_address(),
848+
stale_lower_tick,
849+
stale_upper_tick,
850+
50_000,
851+
)))
852+
.unwrap();
853+
854+
let stale_tick_before = *drifted_profiler
855+
.get_tick(stale_upper_tick)
856+
.expect("stale upper tick should exist");
857+
858+
let swap_quote = actual_profiler
859+
.swap_exact_in(U256::from(expand_to_18_decimals(1)), true, None)
860+
.unwrap();
861+
assert!(swap_quote.crossed_ticks.is_empty());
862+
assert!(swap_quote.tick_after < stale_upper_tick);
863+
864+
let swap_event = swap_quote.to_swap_event(
865+
arbitrum(),
866+
uniswap_v3(),
867+
pool_identifier,
868+
create_block_position(),
869+
UnixNanos::default(),
870+
UnixNanos::default(),
871+
user_address(),
872+
user_address(),
873+
);
874+
let amount_specified = swap_event.amount0;
875+
let drifted_quote = drifted_profiler
876+
.simulate_swap_through_ticks(amount_specified, true, swap_event.sqrt_price_x96, true)
877+
.unwrap();
878+
assert!(
879+
drifted_quote
880+
.crossed_ticks
881+
.iter()
882+
.any(|crossed| crossed.tick == stale_upper_tick)
883+
);
884+
assert_ne!(drifted_quote.liquidity_after, swap_event.liquidity);
885+
886+
let fee_growth_global_1_before = drifted_profiler.state.fee_growth_global_1;
887+
let protocol_fees_token0_before = drifted_profiler.state.protocol_fees_token0;
888+
let protocol_fees_token1_before = drifted_profiler.state.protocol_fees_token1;
889+
890+
drifted_profiler
891+
.process(&DexPoolData::Swap(swap_event))
892+
.unwrap();
893+
894+
let stale_tick_after = drifted_profiler
895+
.get_tick(stale_upper_tick)
896+
.expect("stale upper tick should remain");
897+
assert_eq!(
898+
stale_tick_after.fee_growth_outside_0,
899+
stale_tick_before.fee_growth_outside_0
900+
);
901+
assert_eq!(
902+
stale_tick_after.fee_growth_outside_1,
903+
stale_tick_before.fee_growth_outside_1
904+
);
905+
assert_eq!(drifted_profiler.state.current_tick, swap_quote.tick_after);
906+
assert_eq!(
907+
drifted_profiler.state.price_sqrt_ratio_x96,
908+
swap_quote.sqrt_price_after_x96
909+
);
910+
assert_eq!(
911+
drifted_profiler.state.fee_growth_global_0,
912+
drifted_quote.fee_growth_global_after
913+
);
914+
assert_eq!(
915+
drifted_profiler.state.fee_growth_global_1,
916+
fee_growth_global_1_before
917+
);
918+
assert_eq!(
919+
drifted_profiler.state.protocol_fees_token0,
920+
protocol_fees_token0_before + drifted_quote.protocol_fee
921+
);
922+
assert_eq!(
923+
drifted_profiler.state.protocol_fees_token1,
924+
protocol_fees_token1_before
925+
);
926+
assert_eq!(
927+
drifted_profiler.tick_map.liquidity,
928+
swap_quote.liquidity_after
929+
);
930+
}
931+
831932
#[rstest]
832933
fn test_set_fee_protocol_applies_to_state_and_snapshot(mut profiler: PoolProfiler) {
833934
let min_tick = PoolTick::get_min_tick(TICK_SPACING);

0 commit comments

Comments
 (0)