Skip to main content

defi_tracker_lifecycle/lifecycle/
mod.rs

1pub mod adapters;
2pub mod mapping;
3
4/// Terminal state of a DeFi order lifecycle.
5#[derive(
6    Debug,
7    Clone,
8    Copy,
9    PartialEq,
10    Eq,
11    strum_macros::Display,
12    strum_macros::EnumString,
13    strum_macros::AsRefStr,
14)]
15#[strum(serialize_all = "lowercase")]
16pub enum TerminalStatus {
17    /// All fills executed — order fully satisfied.
18    Completed,
19    /// User or protocol explicitly cancelled the order.
20    Cancelled,
21    /// Order reached its expiration time without completing.
22    Expired,
23}
24
25/// A state-mutating action the consumer wants to apply to an order.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum LifecycleTransition {
28    /// Order was created on-chain.
29    Create,
30    /// An incremental fill occurred (partial or full).
31    FillDelta,
32    /// Order reached a terminal state.
33    Close { status: TerminalStatus },
34    /// Non-state-mutating update (e.g. diagnostic events, display snapshots).
35    /// Always accepted, even after the order is terminal.
36    MetadataOnly,
37}
38
39/// Result of [`LifecycleEngine::decide_transition`].
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum TransitionDecision {
42    /// Transition is valid — the consumer should apply it.
43    Apply,
44    /// Order is already terminal; this state-mutating transition is rejected.
45    IgnoreTerminalViolation,
46}
47
48/// The result of converting a cumulative snapshot into an incremental delta.
49///
50/// `delta` is always `>= 0`. If the snapshot regressed (new total < stored total),
51/// `delta` is clamped to 0 and `regression` is set to `true`.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct SnapshotDelta {
54    /// Non-negative increment to add to the stored total.
55    pub delta: i64,
56    /// `true` when the snapshot total was less than the stored total.
57    pub regression: bool,
58}
59
60/// Stateless decision engine for order lifecycle state machines.
61pub struct LifecycleEngine;
62
63impl LifecycleEngine {
64    /// Decides whether `transition` should be applied given the order's current terminal state.
65    ///
66    /// Non-terminal orders (`None`) accept all transitions.
67    /// Terminal orders only accept [`LifecycleTransition::MetadataOnly`].
68    pub fn decide_transition(
69        current_terminal: Option<TerminalStatus>,
70        transition: LifecycleTransition,
71    ) -> TransitionDecision {
72        if current_terminal.is_none() {
73            return TransitionDecision::Apply;
74        }
75
76        match transition {
77            LifecycleTransition::MetadataOnly => TransitionDecision::Apply,
78            LifecycleTransition::Create
79            | LifecycleTransition::FillDelta
80            | LifecycleTransition::Close { .. } => TransitionDecision::IgnoreTerminalViolation,
81        }
82    }
83
84    /// Converts a cumulative snapshot into a non-negative delta relative to `stored_total`.
85    ///
86    /// If the snapshot regressed, delta is clamped to 0 and `regression` is flagged.
87    pub fn normalize_snapshot_to_delta(stored_total: i64, snapshot_total: i64) -> SnapshotDelta {
88        let delta = snapshot_total.saturating_sub(stored_total).max(0);
89        SnapshotDelta {
90            delta,
91            regression: snapshot_total < stored_total,
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::{
99        LifecycleEngine, LifecycleTransition, SnapshotDelta, TerminalStatus, TransitionDecision,
100    };
101
102    fn lcg_next(state: &mut u64) -> u64 {
103        *state = state
104            .wrapping_mul(6_364_136_223_846_793_005)
105            .wrapping_add(1);
106        *state
107    }
108
109    fn random_transition(state: &mut u64) -> LifecycleTransition {
110        match lcg_next(state) % 6 {
111            0 => LifecycleTransition::Create,
112            1 => LifecycleTransition::FillDelta,
113            2 => LifecycleTransition::Close {
114                status: TerminalStatus::Completed,
115            },
116            3 => LifecycleTransition::Close {
117                status: TerminalStatus::Cancelled,
118            },
119            4 => LifecycleTransition::Close {
120                status: TerminalStatus::Expired,
121            },
122            _ => LifecycleTransition::MetadataOnly,
123        }
124    }
125
126    #[test]
127    fn terminal_status_roundtrip() {
128        assert_eq!(
129            "completed".parse::<TerminalStatus>().ok(),
130            Some(TerminalStatus::Completed)
131        );
132        assert_eq!(
133            "cancelled".parse::<TerminalStatus>().ok(),
134            Some(TerminalStatus::Cancelled)
135        );
136        assert_eq!(
137            "expired".parse::<TerminalStatus>().ok(),
138            Some(TerminalStatus::Expired)
139        );
140        assert_eq!("active".parse::<TerminalStatus>().ok(), None);
141        assert_eq!(TerminalStatus::Completed.to_string(), "completed");
142    }
143
144    #[test]
145    fn terminal_orders_reject_state_mutating_transitions() {
146        let current = Some(TerminalStatus::Completed);
147        assert_eq!(
148            LifecycleEngine::decide_transition(current, LifecycleTransition::Create),
149            TransitionDecision::IgnoreTerminalViolation
150        );
151        assert_eq!(
152            LifecycleEngine::decide_transition(current, LifecycleTransition::FillDelta),
153            TransitionDecision::IgnoreTerminalViolation
154        );
155        assert_eq!(
156            LifecycleEngine::decide_transition(
157                current,
158                LifecycleTransition::Close {
159                    status: TerminalStatus::Cancelled
160                }
161            ),
162            TransitionDecision::IgnoreTerminalViolation
163        );
164        assert_eq!(
165            LifecycleEngine::decide_transition(current, LifecycleTransition::MetadataOnly),
166            TransitionDecision::Apply
167        );
168    }
169
170    #[test]
171    fn snapshot_to_delta_never_regresses() {
172        assert_eq!(
173            LifecycleEngine::normalize_snapshot_to_delta(300, 450),
174            SnapshotDelta {
175                delta: 150,
176                regression: false
177            }
178        );
179        assert_eq!(
180            LifecycleEngine::normalize_snapshot_to_delta(300, 300),
181            SnapshotDelta {
182                delta: 0,
183                regression: false
184            }
185        );
186        assert_eq!(
187            LifecycleEngine::normalize_snapshot_to_delta(300, 200),
188            SnapshotDelta {
189                delta: 0,
190                regression: true
191            }
192        );
193    }
194
195    #[test]
196    fn snapshot_to_delta_property_holds_for_randomized_inputs() {
197        let mut seed = 0x00C0_FFEE_u64;
198        for _ in 0..20_000 {
199            let stored_total = (lcg_next(&mut seed) as i64 % 2_000_000) - 1_000_000;
200            let snapshot_total = (lcg_next(&mut seed) as i64 % 2_000_000) - 1_000_000;
201            let normalized =
202                LifecycleEngine::normalize_snapshot_to_delta(stored_total, snapshot_total);
203
204            assert!(normalized.delta >= 0);
205            assert_eq!(normalized.regression, snapshot_total < stored_total);
206
207            if snapshot_total >= stored_total {
208                assert_eq!(normalized.delta, snapshot_total - stored_total);
209            } else {
210                assert_eq!(normalized.delta, 0);
211            }
212        }
213    }
214
215    #[test]
216    fn terminal_immutability_property_holds_for_all_terminals() {
217        let terminal_statuses = [
218            TerminalStatus::Completed,
219            TerminalStatus::Cancelled,
220            TerminalStatus::Expired,
221        ];
222        let mut seed = 0xDEAD_BEEF_u64;
223
224        for status in terminal_statuses {
225            for _ in 0..5_000 {
226                let transition = random_transition(&mut seed);
227                let decision = LifecycleEngine::decide_transition(Some(status), transition);
228                match transition {
229                    LifecycleTransition::MetadataOnly => {
230                        assert_eq!(decision, TransitionDecision::Apply);
231                    }
232                    LifecycleTransition::Create
233                    | LifecycleTransition::FillDelta
234                    | LifecycleTransition::Close { .. } => {
235                        assert_eq!(decision, TransitionDecision::IgnoreTerminalViolation);
236                    }
237                }
238            }
239        }
240    }
241
242    #[test]
243    fn non_terminal_statuses_do_not_block_transitions() {
244        let mut seed = 0xA11CE_u64;
245
246        for _ in 0..12_000 {
247            let transition = random_transition(&mut seed);
248            let decision = LifecycleEngine::decide_transition(None, transition);
249            assert_eq!(decision, TransitionDecision::Apply);
250        }
251    }
252
253    fn apply_sequence(steps: &[(LifecycleTransition, TransitionDecision)]) {
254        let mut current_terminal: Option<TerminalStatus> = None;
255
256        for (i, (transition, expected_decision)) in steps.iter().enumerate() {
257            let decision = LifecycleEngine::decide_transition(current_terminal, *transition);
258            assert_eq!(
259                decision, *expected_decision,
260                "step {i}: expected {expected_decision:?} for {transition:?} with terminal {current_terminal:?}"
261            );
262
263            if decision == TransitionDecision::Apply
264                && let LifecycleTransition::Close { status } = transition
265            {
266                current_terminal = Some(*status);
267            }
268        }
269    }
270
271    #[test]
272    fn lifecycle_sequence_dca_happy_path() {
273        apply_sequence(&[
274            (LifecycleTransition::Create, TransitionDecision::Apply),
275            (LifecycleTransition::FillDelta, TransitionDecision::Apply),
276            (LifecycleTransition::FillDelta, TransitionDecision::Apply),
277            (
278                LifecycleTransition::Close {
279                    status: TerminalStatus::Completed,
280                },
281                TransitionDecision::Apply,
282            ),
283            (
284                LifecycleTransition::FillDelta,
285                TransitionDecision::IgnoreTerminalViolation,
286            ),
287        ]);
288    }
289
290    #[test]
291    fn lifecycle_sequence_limit_cancel() {
292        apply_sequence(&[
293            (LifecycleTransition::Create, TransitionDecision::Apply),
294            (LifecycleTransition::FillDelta, TransitionDecision::Apply),
295            (
296                LifecycleTransition::Close {
297                    status: TerminalStatus::Cancelled,
298                },
299                TransitionDecision::Apply,
300            ),
301            (
302                LifecycleTransition::Create,
303                TransitionDecision::IgnoreTerminalViolation,
304            ),
305        ]);
306    }
307
308    #[test]
309    fn lifecycle_sequence_limit_expired() {
310        apply_sequence(&[
311            (LifecycleTransition::Create, TransitionDecision::Apply),
312            (
313                LifecycleTransition::Close {
314                    status: TerminalStatus::Expired,
315                },
316                TransitionDecision::Apply,
317            ),
318            (
319                LifecycleTransition::FillDelta,
320                TransitionDecision::IgnoreTerminalViolation,
321            ),
322        ]);
323    }
324
325    #[test]
326    fn lifecycle_sequence_terminal_still_accepts_metadata() {
327        apply_sequence(&[
328            (LifecycleTransition::Create, TransitionDecision::Apply),
329            (
330                LifecycleTransition::Close {
331                    status: TerminalStatus::Completed,
332                },
333                TransitionDecision::Apply,
334            ),
335            (LifecycleTransition::MetadataOnly, TransitionDecision::Apply),
336            (LifecycleTransition::MetadataOnly, TransitionDecision::Apply),
337            (
338                LifecycleTransition::Close {
339                    status: TerminalStatus::Cancelled,
340                },
341                TransitionDecision::IgnoreTerminalViolation,
342            ),
343        ]);
344    }
345}