Skip to main content

nautilus_model/defi/pool_analysis/
compare.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Pool profiler state comparison utilities.
17
18use super::{position::PoolPosition, profiler::PoolProfiler};
19use crate::defi::pool_analysis::snapshot::PoolSnapshot;
20
21/// Result of comparing a replayed pool profiler against an on-chain snapshot.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PoolProfilerComparison {
24    /// All compared fields match.
25    Match,
26    /// Only the sqrt price differs; structural pool state still matches.
27    SqrtPriceMismatch,
28    /// Only the fee protocol differs; structural pool state still matches.
29    FeeProtocolMismatch,
30    /// Only the accrued protocol-fee balances differ; structural pool state still matches.
31    ProtocolFeesMismatch,
32    /// One or more structural fields differ.
33    Mismatch,
34}
35
36impl PoolProfilerComparison {
37    /// Returns `true` when every compared field matches.
38    #[must_use]
39    pub const fn is_exact_match(self) -> bool {
40        matches!(self, Self::Match)
41    }
42
43    /// Returns `true` when the snapshot can seed the profiler cache.
44    #[must_use]
45    pub const fn is_valid_for_snapshot(self) -> bool {
46        matches!(
47            self,
48            Self::Match
49                | Self::SqrtPriceMismatch
50                | Self::FeeProtocolMismatch
51                | Self::ProtocolFeesMismatch
52        )
53    }
54}
55
56/// Compares a pool profiler's internal state with on-chain state to verify consistency.
57///
58/// This function validates that the profiler's tracked state matches the actual on-chain
59/// pool state by comparing global pool parameters, tick data, and position data.
60/// Structural mismatches are logged as errors, sqrt price mismatches are logged as warnings,
61/// and matches are logged as info.
62///
63/// # Arguments
64///
65/// - `profiler` - The pool profiler whose state should be compared
66/// - `snapshot` - The on-chain snapshot to compare against
67///
68/// # Panics
69///
70/// Panics if the profiler has not been initialized.
71///
72/// # Returns
73///
74/// Returns `true` if all compared values match, `false` if any mismatches are detected.
75#[must_use]
76pub fn compare_pool_profiler(profiler: &PoolProfiler, snapshot: &PoolSnapshot) -> bool {
77    compare_pool_profiler_detailed(profiler, snapshot).is_exact_match()
78}
79
80/// Compares a pool profiler's internal state with on-chain state and classifies mismatches.
81///
82/// Sqrt price can differ when replay is event-scoped but the RPC snapshot is block-scoped. Fee
83/// protocol can differ until `SetFeeProtocol` events are indexed and applied during replay, leaving
84/// the profiler value lagging the on-chain one. Accrued protocol-fee balances can differ when
85/// per-step rounding during replay accrual diverges from the on-chain accumulator. These mismatches
86/// are non-blocking when tick, liquidity, ticks, and positions all match.
87///
88/// # Panics
89///
90/// Panics if the profiler has not been initialized.
91#[must_use]
92pub fn compare_pool_profiler_detailed(
93    profiler: &PoolProfiler,
94    snapshot: &PoolSnapshot,
95) -> PoolProfilerComparison {
96    assert!(profiler.is_initialized, "Profiler is not initialized");
97
98    let mut structural_match = true;
99    let mut sqrt_price_matches = true;
100    let mut fee_protocol_matches = true;
101    let mut protocol_fees_match = true;
102    let total_ticks = snapshot.ticks.len();
103    let total_positions = snapshot.positions.len();
104
105    if snapshot.state.current_tick == profiler.state.current_tick {
106        log::info!("✓ current_tick matches: {}", snapshot.state.current_tick);
107    } else {
108        log::error!(
109            "Tick mismatch: profiler={}, compared={}",
110            profiler.state.current_tick,
111            snapshot.state.current_tick
112        );
113        structural_match = false;
114    }
115
116    if snapshot.state.price_sqrt_ratio_x96 == profiler.state.price_sqrt_ratio_x96 {
117        log::info!(
118            "✓ sqrt_price_x96 matches: {}",
119            profiler.state.price_sqrt_ratio_x96,
120        );
121    } else {
122        log::warn!(
123            "Sqrt ratio mismatch: profiler={}, compared={}",
124            profiler.state.price_sqrt_ratio_x96,
125            snapshot.state.price_sqrt_ratio_x96
126        );
127        sqrt_price_matches = false;
128    }
129
130    if snapshot.state.fee_protocol == profiler.state.fee_protocol {
131        log::info!("✓ fee_protocol matches: {}", snapshot.state.fee_protocol);
132    } else {
133        log::warn!(
134            "Fee protocol mismatch: profiler={}, compared={}",
135            profiler.state.fee_protocol,
136            snapshot.state.fee_protocol
137        );
138        fee_protocol_matches = false;
139    }
140
141    if snapshot.state.protocol_fees_token0 == profiler.state.protocol_fees_token0
142        && snapshot.state.protocol_fees_token1 == profiler.state.protocol_fees_token1
143    {
144        log::info!(
145            "✓ protocol_fees match: token0={}, token1={}",
146            snapshot.state.protocol_fees_token0,
147            snapshot.state.protocol_fees_token1
148        );
149    } else {
150        log::warn!(
151            "Protocol fees mismatch: profiler=(token0={}, token1={}), compared=(token0={}, token1={})",
152            profiler.state.protocol_fees_token0,
153            profiler.state.protocol_fees_token1,
154            snapshot.state.protocol_fees_token0,
155            snapshot.state.protocol_fees_token1
156        );
157        protocol_fees_match = false;
158    }
159
160    if snapshot.state.liquidity == profiler.tick_map.liquidity {
161        log::info!("✓ liquidity matches: {}", snapshot.state.liquidity);
162    } else {
163        log::error!(
164            "Liquidity mismatch: profiler={}, compared={}",
165            profiler.tick_map.liquidity,
166            snapshot.state.liquidity
167        );
168        structural_match = false;
169    }
170
171    // TODO add growth fee checking
172
173    // Check ticks
174    let mut tick_mismatches = 0;
175
176    for tick in &snapshot.ticks {
177        if let Some(profiler_tick) = profiler.get_tick(tick.value) {
178            let mut all_tick_fields_matching = true;
179
180            if profiler_tick.liquidity_net != tick.liquidity_net {
181                log::error!(
182                    "Tick {} mismatch on net liquidity: profiler={}, compared={}",
183                    tick.value,
184                    profiler_tick.liquidity_net,
185                    tick.liquidity_net
186                );
187                all_tick_fields_matching = false;
188            }
189
190            if profiler_tick.liquidity_gross != tick.liquidity_gross {
191                log::error!(
192                    "Tick {} mismatch on gross liquidity: profiler={}, compared={}",
193                    tick.value,
194                    profiler_tick.liquidity_gross,
195                    tick.liquidity_gross
196                );
197                all_tick_fields_matching = false;
198            }
199            // TODO add fees checking per tick
200
201            if !all_tick_fields_matching {
202                tick_mismatches += 1;
203                structural_match = false;
204            }
205        } else {
206            log::error!(
207                "Tick {} not found in the profiler but provided in the compare mapping",
208                tick.value
209            );
210            structural_match = false;
211        }
212    }
213
214    if tick_mismatches == 0 {
215        log::info!("✓ Provided {total_ticks} ticks with liquidity net and gross are matching");
216    }
217
218    // Check positions
219    let mut position_mismatches = 0;
220
221    for position in &snapshot.positions {
222        if let Some(profiler_position) =
223            profiler.get_position(&position.owner, position.tick_lower, position.tick_upper)
224        {
225            let position_key = PoolPosition::get_position_key(
226                &position.owner,
227                position.tick_lower,
228                position.tick_upper,
229            );
230
231            if position.liquidity != profiler_position.liquidity {
232                log::error!(
233                    "Position '{}' mismatch on liquidity: profiler={}, compared={}",
234                    position_key,
235                    profiler_position.liquidity,
236                    position.liquidity
237                );
238                position_mismatches += 1;
239            }
240            // TODO add fees and tokens owned checking
241        } else {
242            log::error!(
243                "Position {} not found in the profiler but provided in the compare mapping",
244                position.owner
245            );
246            structural_match = false;
247        }
248    }
249
250    if position_mismatches == 0 {
251        log::info!("✓ Provided {total_positions} active positions with liquidity are matching");
252    } else {
253        structural_match = false;
254    }
255
256    if !structural_match {
257        PoolProfilerComparison::Mismatch
258    } else if !sqrt_price_matches {
259        log::warn!("Pool profiler sqrt ratio differs, but all structural state matches");
260        PoolProfilerComparison::SqrtPriceMismatch
261    } else if !fee_protocol_matches {
262        log::warn!("Pool profiler fee protocol differs, but all structural state matches");
263        PoolProfilerComparison::FeeProtocolMismatch
264    } else if !protocol_fees_match {
265        log::warn!("Pool profiler protocol fees differ, but all structural state matches");
266        PoolProfilerComparison::ProtocolFeesMismatch
267    } else {
268        PoolProfilerComparison::Match
269    }
270}