Skip to main content

nautilus_model/defi/pool_analysis/
error.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//! Structured errors produced by [`PoolProfiler`](super::PoolProfiler) while replaying
17//! historical pool events.
18//!
19//! Each variant carries enough context (pool, block, transaction/log position, event
20//! kind, and the simulated vs. observed values that disagreed) for downstream consumers
21//! to decide between hard-halt and skip-and-log on a per-pool basis.
22
23use std::fmt::Display;
24
25use crate::{defi::PoolIdentifier, identifiers::InstrumentId};
26
27/// The kind of pool event being processed when an error was produced.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum PoolEventKind {
30    Initialize,
31    Swap,
32    Mint,
33    Burn,
34    Collect,
35    Flash,
36}
37
38impl Display for PoolEventKind {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        let name = match self {
41            Self::Initialize => "Initialize",
42            Self::Swap => "Swap",
43            Self::Mint => "Mint",
44            Self::Burn => "Burn",
45            Self::Collect => "Collect",
46            Self::Flash => "Flash",
47        };
48        f.write_str(name)
49    }
50}
51
52/// Identifies the source event for a [`PoolProfilerError`].
53#[derive(Debug, Clone)]
54pub struct PoolEventLocation {
55    pub instrument_id: InstrumentId,
56    pub pool_identifier: PoolIdentifier,
57    pub block: u64,
58    pub transaction_index: u32,
59    pub log_index: u32,
60    pub event_kind: PoolEventKind,
61}
62
63impl Display for PoolEventLocation {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        write!(
66            f,
67            "pool={} ({}) block={} tx_index={} log_index={} event={}",
68            self.instrument_id,
69            self.pool_identifier,
70            self.block,
71            self.transaction_index,
72            self.log_index,
73            self.event_kind,
74        )
75    }
76}
77
78/// Low-level liquidity arithmetic error surfaced by
79/// [`try_liquidity_math_add`](crate::defi::tick_map::liquidity_math::try_liquidity_math_add).
80#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
81pub enum LiquidityMathError {
82    #[error("Liquidity addition overflow: current={current}, delta={delta}")]
83    Overflow { current: u128, delta: u128 },
84    #[error("Liquidity subtraction underflow: current={current}, delta={delta}")]
85    Underflow { current: u128, delta: u128 },
86}
87
88/// Structured errors emitted by the pool profiler during event replay.
89///
90/// Each variant carries enough context to identify the offending pool, event, and the
91/// simulated vs. observed values that disagreed, so capture pipelines can choose
92/// between hard-halt and skip-and-log on a per-pool basis.
93#[derive(Debug, thiserror::Error)]
94pub enum PoolProfilerError {
95    #[error("Pool {instrument_id} ({pool_identifier}) already initialized")]
96    AlreadyInitialized {
97        instrument_id: InstrumentId,
98        pool_identifier: PoolIdentifier,
99    },
100
101    #[error(
102        "Pool {instrument_id} ({pool_identifier}) is not initialized while processing {event_kind}"
103    )]
104    NotInitialized {
105        instrument_id: InstrumentId,
106        pool_identifier: PoolIdentifier,
107        event_kind: PoolEventKind,
108    },
109
110    #[error(
111        "Initial tick mismatch for pool {instrument_id} ({pool_identifier}): pool.initial_tick={initial_tick}, computed_from_sqrt_price={calculated_tick}"
112    )]
113    InitialTickMismatch {
114        instrument_id: InstrumentId,
115        pool_identifier: PoolIdentifier,
116        initial_tick: i32,
117        calculated_tick: i32,
118    },
119
120    #[error(
121        "Swap replay mismatch at {location}: simulated_tick={simulated_tick}, event_tick={event_tick}, simulated_liquidity={simulated_liquidity}, event_liquidity={event_liquidity}, simulated_crossed_tick_count={simulated_crossed_tick_count}, anchoring_crossed_tick={anchoring_crossed_tick:?}"
122    )]
123    SwapReplayMismatch {
124        location: PoolEventLocation,
125        simulated_tick: i32,
126        event_tick: i32,
127        simulated_liquidity: u128,
128        event_liquidity: u128,
129        simulated_crossed_tick_count: usize,
130        anchoring_crossed_tick: Option<i32>,
131    },
132
133    #[error("Liquidity overflow at {location}: current={current}, delta={delta}")]
134    LiquidityOverflow {
135        location: PoolEventLocation,
136        current: u128,
137        delta: u128,
138    },
139
140    #[error("Liquidity underflow at {location}: current={current}, delta={delta}")]
141    LiquidityUnderflow {
142        location: PoolEventLocation,
143        current: u128,
144        delta: u128,
145    },
146
147    #[error(
148        "No events processed yet for pool {instrument_id} ({pool_identifier}); cannot extract snapshot"
149    )]
150    NoEventsProcessed {
151        instrument_id: InstrumentId,
152        pool_identifier: PoolIdentifier,
153    },
154}
155
156impl PoolProfilerError {
157    /// Returns the event location for variants that carry one.
158    #[must_use]
159    pub fn location(&self) -> Option<&PoolEventLocation> {
160        match self {
161            Self::LiquidityOverflow { location, .. }
162            | Self::LiquidityUnderflow { location, .. }
163            | Self::SwapReplayMismatch { location, .. } => Some(location),
164            _ => None,
165        }
166    }
167}
168
169/// Maps a [`LiquidityMathError`] to a [`PoolProfilerError`] using event context.
170#[must_use]
171pub fn liquidity_error_with_location(
172    err: LiquidityMathError,
173    location: PoolEventLocation,
174) -> PoolProfilerError {
175    match err {
176        LiquidityMathError::Overflow { current, delta } => PoolProfilerError::LiquidityOverflow {
177            location,
178            current,
179            delta,
180        },
181        LiquidityMathError::Underflow { current, delta } => PoolProfilerError::LiquidityUnderflow {
182            location,
183            current,
184            delta,
185        },
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use alloy_primitives::address;
192    use rstest::{fixture, rstest};
193
194    use super::*;
195
196    #[fixture]
197    fn location() -> PoolEventLocation {
198        PoolEventLocation {
199            instrument_id: InstrumentId::from(
200                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234.Arbitrum:UniswapV3",
201            ),
202            pool_identifier: PoolIdentifier::from_address(address!(
203                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234"
204            )),
205            block: 12_345,
206            transaction_index: 7,
207            log_index: 42,
208            event_kind: PoolEventKind::Burn,
209        }
210    }
211
212    #[rstest]
213    fn test_liquidity_error_with_location_maps_overflow(location: PoolEventLocation) {
214        let err = liquidity_error_with_location(
215            LiquidityMathError::Overflow {
216                current: 10,
217                delta: 20,
218            },
219            location.clone(),
220        );
221
222        match err {
223            PoolProfilerError::LiquidityOverflow {
224                location: out_loc,
225                current,
226                delta,
227            } => {
228                assert_eq!(current, 10);
229                assert_eq!(delta, 20);
230                assert_eq!(out_loc.instrument_id, location.instrument_id);
231                assert_eq!(out_loc.pool_identifier, location.pool_identifier);
232                assert_eq!(out_loc.block, location.block);
233                assert_eq!(out_loc.transaction_index, location.transaction_index);
234                assert_eq!(out_loc.log_index, location.log_index);
235                assert_eq!(out_loc.event_kind, location.event_kind);
236            }
237            other => panic!("expected LiquidityOverflow, was {other:?}"),
238        }
239    }
240
241    #[rstest]
242    fn test_liquidity_error_with_location_maps_underflow(location: PoolEventLocation) {
243        let err = liquidity_error_with_location(
244            LiquidityMathError::Underflow {
245                current: 5,
246                delta: 9,
247            },
248            location,
249        );
250
251        match err {
252            PoolProfilerError::LiquidityUnderflow { current, delta, .. } => {
253                assert_eq!(current, 5);
254                assert_eq!(delta, 9);
255            }
256            other => panic!("expected LiquidityUnderflow, was {other:?}"),
257        }
258    }
259
260    #[rstest]
261    fn test_pool_profiler_error_location_accessor(location: PoolEventLocation) {
262        let overflow = PoolProfilerError::LiquidityOverflow {
263            location: location.clone(),
264            current: 1,
265            delta: 2,
266        };
267        assert!(overflow.location().is_some());
268
269        let underflow = PoolProfilerError::LiquidityUnderflow {
270            location,
271            current: 3,
272            delta: 4,
273        };
274        assert!(underflow.location().is_some());
275
276        let not_init = PoolProfilerError::NotInitialized {
277            instrument_id: InstrumentId::from(
278                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234.Arbitrum:UniswapV3",
279            ),
280            pool_identifier: PoolIdentifier::from_address(address!(
281                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234"
282            )),
283            event_kind: PoolEventKind::Swap,
284        };
285        assert!(not_init.location().is_none());
286    }
287
288    #[rstest]
289    #[case(PoolEventKind::Initialize, "Initialize")]
290    #[case(PoolEventKind::Swap, "Swap")]
291    #[case(PoolEventKind::Mint, "Mint")]
292    #[case(PoolEventKind::Burn, "Burn")]
293    #[case(PoolEventKind::Collect, "Collect")]
294    #[case(PoolEventKind::Flash, "Flash")]
295    fn test_pool_event_kind_display(#[case] kind: PoolEventKind, #[case] expected: &str) {
296        assert_eq!(kind.to_string(), expected);
297    }
298
299    #[rstest]
300    fn test_pool_event_location_display_contains_required_fields(location: PoolEventLocation) {
301        let s = location.to_string();
302        assert!(s.contains("0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234"));
303        assert!(s.contains("Arbitrum:UniswapV3"));
304        assert!(s.contains("block=12345"));
305        assert!(s.contains("tx_index=7"));
306        assert!(s.contains("log_index=42"));
307        assert!(s.contains("event=Burn"));
308    }
309
310    #[rstest]
311    fn test_pool_profiler_error_display_carries_full_context(location: PoolEventLocation) {
312        // LiquidityUnderflow: pool, block, tx_index, log_index, event_kind.
313        let underflow = PoolProfilerError::LiquidityUnderflow {
314            location: location.clone(),
315            current: 10,
316            delta: 99,
317        };
318        let s = underflow.to_string();
319        assert!(s.contains("0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234"));
320        assert!(s.contains("block=12345"));
321        assert!(s.contains("tx_index=7"));
322        assert!(s.contains("log_index=42"));
323        assert!(s.contains("event=Burn"));
324        assert!(s.contains("current=10"));
325        assert!(s.contains("delta=99"));
326
327        let overflow = PoolProfilerError::LiquidityOverflow {
328            location,
329            current: 100,
330            delta: 200,
331        };
332        let s = overflow.to_string();
333        assert!(s.contains("current=100"));
334        assert!(s.contains("delta=200"));
335
336        let not_init = PoolProfilerError::NotInitialized {
337            instrument_id: InstrumentId::from(
338                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234.Arbitrum:UniswapV3",
339            ),
340            pool_identifier: PoolIdentifier::from_address(address!(
341                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234"
342            )),
343            event_kind: PoolEventKind::Mint,
344        };
345        let s = not_init.to_string();
346        assert!(s.contains("Arbitrum:UniswapV3"));
347        assert!(s.contains("Mint"));
348        assert!(s.contains("not initialized"));
349
350        let mismatch = PoolProfilerError::InitialTickMismatch {
351            instrument_id: InstrumentId::from(
352                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234.Arbitrum:UniswapV3",
353            ),
354            pool_identifier: PoolIdentifier::from_address(address!(
355                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234"
356            )),
357            initial_tick: -100,
358            calculated_tick: 200,
359        };
360        let s = mismatch.to_string();
361        assert!(s.contains("initial_tick=-100"));
362        assert!(s.contains("computed_from_sqrt_price=200"));
363    }
364}