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("Liquidity overflow at {location}: current={current}, delta={delta}")]
121    LiquidityOverflow {
122        location: PoolEventLocation,
123        current: u128,
124        delta: u128,
125    },
126
127    #[error("Liquidity underflow at {location}: current={current}, delta={delta}")]
128    LiquidityUnderflow {
129        location: PoolEventLocation,
130        current: u128,
131        delta: u128,
132    },
133
134    #[error(
135        "No events processed yet for pool {instrument_id} ({pool_identifier}); cannot extract snapshot"
136    )]
137    NoEventsProcessed {
138        instrument_id: InstrumentId,
139        pool_identifier: PoolIdentifier,
140    },
141}
142
143impl PoolProfilerError {
144    /// Returns the event location for variants that carry one.
145    #[must_use]
146    pub fn location(&self) -> Option<&PoolEventLocation> {
147        match self {
148            Self::LiquidityOverflow { location, .. }
149            | Self::LiquidityUnderflow { location, .. } => Some(location),
150            _ => None,
151        }
152    }
153}
154
155/// Maps a [`LiquidityMathError`] to a [`PoolProfilerError`] using event context.
156#[must_use]
157pub fn liquidity_error_with_location(
158    err: LiquidityMathError,
159    location: PoolEventLocation,
160) -> PoolProfilerError {
161    match err {
162        LiquidityMathError::Overflow { current, delta } => PoolProfilerError::LiquidityOverflow {
163            location,
164            current,
165            delta,
166        },
167        LiquidityMathError::Underflow { current, delta } => PoolProfilerError::LiquidityUnderflow {
168            location,
169            current,
170            delta,
171        },
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use alloy_primitives::address;
178    use rstest::{fixture, rstest};
179
180    use super::*;
181
182    #[fixture]
183    fn location() -> PoolEventLocation {
184        PoolEventLocation {
185            instrument_id: InstrumentId::from(
186                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234.Arbitrum:UniswapV3",
187            ),
188            pool_identifier: PoolIdentifier::from_address(address!(
189                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234"
190            )),
191            block: 12_345,
192            transaction_index: 7,
193            log_index: 42,
194            event_kind: PoolEventKind::Burn,
195        }
196    }
197
198    #[rstest]
199    fn test_liquidity_error_with_location_maps_overflow(location: PoolEventLocation) {
200        let err = liquidity_error_with_location(
201            LiquidityMathError::Overflow {
202                current: 10,
203                delta: 20,
204            },
205            location.clone(),
206        );
207
208        match err {
209            PoolProfilerError::LiquidityOverflow {
210                location: out_loc,
211                current,
212                delta,
213            } => {
214                assert_eq!(current, 10);
215                assert_eq!(delta, 20);
216                assert_eq!(out_loc.instrument_id, location.instrument_id);
217                assert_eq!(out_loc.pool_identifier, location.pool_identifier);
218                assert_eq!(out_loc.block, location.block);
219                assert_eq!(out_loc.transaction_index, location.transaction_index);
220                assert_eq!(out_loc.log_index, location.log_index);
221                assert_eq!(out_loc.event_kind, location.event_kind);
222            }
223            other => panic!("expected LiquidityOverflow, was {other:?}"),
224        }
225    }
226
227    #[rstest]
228    fn test_liquidity_error_with_location_maps_underflow(location: PoolEventLocation) {
229        let err = liquidity_error_with_location(
230            LiquidityMathError::Underflow {
231                current: 5,
232                delta: 9,
233            },
234            location,
235        );
236
237        match err {
238            PoolProfilerError::LiquidityUnderflow { current, delta, .. } => {
239                assert_eq!(current, 5);
240                assert_eq!(delta, 9);
241            }
242            other => panic!("expected LiquidityUnderflow, was {other:?}"),
243        }
244    }
245
246    #[rstest]
247    fn test_pool_profiler_error_location_accessor(location: PoolEventLocation) {
248        let overflow = PoolProfilerError::LiquidityOverflow {
249            location: location.clone(),
250            current: 1,
251            delta: 2,
252        };
253        assert!(overflow.location().is_some());
254
255        let underflow = PoolProfilerError::LiquidityUnderflow {
256            location,
257            current: 3,
258            delta: 4,
259        };
260        assert!(underflow.location().is_some());
261
262        let not_init = PoolProfilerError::NotInitialized {
263            instrument_id: InstrumentId::from(
264                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234.Arbitrum:UniswapV3",
265            ),
266            pool_identifier: PoolIdentifier::from_address(address!(
267                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234"
268            )),
269            event_kind: PoolEventKind::Swap,
270        };
271        assert!(not_init.location().is_none());
272    }
273
274    #[rstest]
275    #[case(PoolEventKind::Initialize, "Initialize")]
276    #[case(PoolEventKind::Swap, "Swap")]
277    #[case(PoolEventKind::Mint, "Mint")]
278    #[case(PoolEventKind::Burn, "Burn")]
279    #[case(PoolEventKind::Collect, "Collect")]
280    #[case(PoolEventKind::Flash, "Flash")]
281    fn test_pool_event_kind_display(#[case] kind: PoolEventKind, #[case] expected: &str) {
282        assert_eq!(kind.to_string(), expected);
283    }
284
285    #[rstest]
286    fn test_pool_event_location_display_contains_required_fields(location: PoolEventLocation) {
287        let s = location.to_string();
288        assert!(s.contains("0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234"));
289        assert!(s.contains("Arbitrum:UniswapV3"));
290        assert!(s.contains("block=12345"));
291        assert!(s.contains("tx_index=7"));
292        assert!(s.contains("log_index=42"));
293        assert!(s.contains("event=Burn"));
294    }
295
296    #[rstest]
297    fn test_pool_profiler_error_display_carries_full_context(location: PoolEventLocation) {
298        // LiquidityUnderflow: pool, block, tx_index, log_index, event_kind.
299        let underflow = PoolProfilerError::LiquidityUnderflow {
300            location: location.clone(),
301            current: 10,
302            delta: 99,
303        };
304        let s = underflow.to_string();
305        assert!(s.contains("0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234"));
306        assert!(s.contains("block=12345"));
307        assert!(s.contains("tx_index=7"));
308        assert!(s.contains("log_index=42"));
309        assert!(s.contains("event=Burn"));
310        assert!(s.contains("current=10"));
311        assert!(s.contains("delta=99"));
312
313        let overflow = PoolProfilerError::LiquidityOverflow {
314            location,
315            current: 100,
316            delta: 200,
317        };
318        let s = overflow.to_string();
319        assert!(s.contains("current=100"));
320        assert!(s.contains("delta=200"));
321
322        let not_init = PoolProfilerError::NotInitialized {
323            instrument_id: InstrumentId::from(
324                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234.Arbitrum:UniswapV3",
325            ),
326            pool_identifier: PoolIdentifier::from_address(address!(
327                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234"
328            )),
329            event_kind: PoolEventKind::Mint,
330        };
331        let s = not_init.to_string();
332        assert!(s.contains("Arbitrum:UniswapV3"));
333        assert!(s.contains("Mint"));
334        assert!(s.contains("not initialized"));
335
336        let mismatch = PoolProfilerError::InitialTickMismatch {
337            instrument_id: InstrumentId::from(
338                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234.Arbitrum:UniswapV3",
339            ),
340            pool_identifier: PoolIdentifier::from_address(address!(
341                "0xBBf3209130dF7d19356d72Eb8a193e2D9Ec5c234"
342            )),
343            initial_tick: -100,
344            calculated_tick: 200,
345        };
346        let s = mismatch.to_string();
347        assert!(s.contains("initial_tick=-100"));
348        assert!(s.contains("computed_from_sqrt_price=200"));
349    }
350}