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        && snapshot.state.fee_protocol0_basis_points == profiler.state.fee_protocol0_basis_points
132        && snapshot.state.fee_protocol1_basis_points == profiler.state.fee_protocol1_basis_points
133    {
134        log::info!(
135            "✓ fee_protocol matches: packed={}, token0_bps={:?}, token1_bps={:?}",
136            snapshot.state.fee_protocol,
137            snapshot.state.fee_protocol0_basis_points,
138            snapshot.state.fee_protocol1_basis_points,
139        );
140    } else {
141        log::warn!(
142            "Fee protocol mismatch: profiler=(packed={}, token0_bps={:?}, token1_bps={:?}), compared=(packed={}, token0_bps={:?}, token1_bps={:?})",
143            profiler.state.fee_protocol,
144            profiler.state.fee_protocol0_basis_points,
145            profiler.state.fee_protocol1_basis_points,
146            snapshot.state.fee_protocol,
147            snapshot.state.fee_protocol0_basis_points,
148            snapshot.state.fee_protocol1_basis_points,
149        );
150        fee_protocol_matches = false;
151    }
152
153    if snapshot.state.protocol_fees_token0 == profiler.state.protocol_fees_token0
154        && snapshot.state.protocol_fees_token1 == profiler.state.protocol_fees_token1
155    {
156        log::info!(
157            "✓ protocol_fees match: token0={}, token1={}",
158            snapshot.state.protocol_fees_token0,
159            snapshot.state.protocol_fees_token1
160        );
161    } else {
162        log::warn!(
163            "Protocol fees mismatch: profiler=(token0={}, token1={}), compared=(token0={}, token1={})",
164            profiler.state.protocol_fees_token0,
165            profiler.state.protocol_fees_token1,
166            snapshot.state.protocol_fees_token0,
167            snapshot.state.protocol_fees_token1
168        );
169        protocol_fees_match = false;
170    }
171
172    if snapshot.state.liquidity == profiler.tick_map.liquidity {
173        log::info!("✓ liquidity matches: {}", snapshot.state.liquidity);
174    } else {
175        log::error!(
176            "Liquidity mismatch: profiler={}, compared={}",
177            profiler.tick_map.liquidity,
178            snapshot.state.liquidity
179        );
180        structural_match = false;
181    }
182
183    // TODO add growth fee checking
184
185    // Check ticks
186    let mut tick_mismatches = 0;
187
188    for tick in &snapshot.ticks {
189        if let Some(profiler_tick) = profiler.get_tick(tick.value) {
190            let mut all_tick_fields_matching = true;
191
192            if profiler_tick.liquidity_net != tick.liquidity_net {
193                log::error!(
194                    "Tick {} mismatch on net liquidity: profiler={}, compared={}",
195                    tick.value,
196                    profiler_tick.liquidity_net,
197                    tick.liquidity_net
198                );
199                all_tick_fields_matching = false;
200            }
201
202            if profiler_tick.liquidity_gross != tick.liquidity_gross {
203                log::error!(
204                    "Tick {} mismatch on gross liquidity: profiler={}, compared={}",
205                    tick.value,
206                    profiler_tick.liquidity_gross,
207                    tick.liquidity_gross
208                );
209                all_tick_fields_matching = false;
210            }
211            // TODO add fees checking per tick
212
213            if !all_tick_fields_matching {
214                tick_mismatches += 1;
215                structural_match = false;
216            }
217        } else {
218            log::error!(
219                "Tick {} not found in the profiler but provided in the compare mapping",
220                tick.value
221            );
222            structural_match = false;
223        }
224    }
225
226    if tick_mismatches == 0 {
227        log::info!("✓ Provided {total_ticks} ticks with liquidity net and gross are matching");
228    }
229
230    // Check positions
231    let mut position_mismatches = 0;
232
233    for position in &snapshot.positions {
234        if let Some(profiler_position) =
235            profiler.get_position(&position.owner, position.tick_lower, position.tick_upper)
236        {
237            let position_key = PoolPosition::get_position_key(
238                &position.owner,
239                position.tick_lower,
240                position.tick_upper,
241            );
242
243            if position.liquidity != profiler_position.liquidity {
244                log::error!(
245                    "Position '{}' mismatch on liquidity: profiler={}, compared={}",
246                    position_key,
247                    profiler_position.liquidity,
248                    position.liquidity
249                );
250                position_mismatches += 1;
251            }
252            // TODO add fees and tokens owned checking
253        } else {
254            log::error!(
255                "Position {} not found in the profiler but provided in the compare mapping",
256                position.owner
257            );
258            structural_match = false;
259        }
260    }
261
262    if position_mismatches == 0 {
263        log::info!("✓ Provided {total_positions} active positions with liquidity are matching");
264    } else {
265        structural_match = false;
266    }
267
268    if !structural_match {
269        PoolProfilerComparison::Mismatch
270    } else if !sqrt_price_matches {
271        log::warn!("Pool profiler sqrt ratio differs, but all structural state matches");
272        PoolProfilerComparison::SqrtPriceMismatch
273    } else if !fee_protocol_matches {
274        log::warn!("Pool profiler fee protocol differs, but all structural state matches");
275        PoolProfilerComparison::FeeProtocolMismatch
276    } else if !protocol_fees_match {
277        log::warn!("Pool profiler protocol fees differ, but all structural state matches");
278        PoolProfilerComparison::ProtocolFeesMismatch
279    } else {
280        PoolProfilerComparison::Match
281    }
282}