Skip to main content

hypercall/liquidator/
state.rs

1//! Liquidation state machine types and transitions.
2//!
3//! The cutover model distinguishes:
4//! - `healthy`: no liquidation flow active
5//! - `pre_liquidation`: active backend-managed partial liquidation
6//! - `in_liquidation`: active on-chain full liquidation
7//! - `liquidated`: full liquidation resolved
8
9// Data types are now defined in hypercall-types. Re-export everything here for
10// backward compatibility with `use crate::liquidator::state::*` paths.
11pub use hypercall_types::liquidation_state::*;
12
13#[cfg(test)]
14mod tests {
15    use super::*;
16    use crate::rsm::MarginMode;
17    use hypercall_types::WalletAddress;
18    use rust_decimal::Decimal;
19    use rust_decimal_macros::dec;
20
21    fn test_wallet() -> WalletAddress {
22        "0x1234567890123456789012345678901234567890"
23            .parse()
24            .unwrap()
25    }
26
27    fn partial_metadata() -> PartialLiquidationMetadata {
28        PartialLiquidationMetadata {
29            entered_at: 2_000,
30            mm_shortfall: dec!(1000),
31            target_equity: dec!(6000),
32            escalation_deadline: 62_000,
33            last_reprice_at: Some(3_000),
34            active_order_request_ids: vec!["req-1".to_string()],
35            active_order_client_ids: vec!["liq-1".to_string()],
36            bonus_bps: 125,
37            pending_full_auction_id: None,
38            pending_full_request_id: None,
39            pending_full_tx_hash: None,
40            pending_full_margin_needed: None,
41        }
42    }
43
44    #[test]
45    fn test_healthy_status() {
46        let status = AccountLiquidationStatus::healthy(
47            test_wallet(),
48            MarginMode::Standard,
49            dec!(10000),
50            dec!(5000),
51            1000,
52        );
53
54        assert!(status.state.is_healthy());
55        assert!(!status.needs_liquidation());
56        assert_eq!(status.shortfall(), Decimal::ZERO);
57        assert_eq!(status.maintenance_margin, dec!(5000));
58    }
59
60    #[test]
61    fn test_pre_liquidation_transition() {
62        let mut status = AccountLiquidationStatus::healthy(
63            test_wallet(),
64            MarginMode::Standard,
65            dec!(4000),
66            dec!(5000),
67            1000,
68        );
69
70        assert!(status.needs_liquidation());
71        status.enter_pre_liquidation(partial_metadata(), 2_000);
72
73        assert!(status.state.is_pre_liquidation());
74        assert!(status.state.should_block_risk_increasing());
75
76        match &status.state {
77            LiquidationState::PreLiquidation(metadata) => {
78                assert_eq!(metadata.mm_shortfall, dec!(1000));
79                assert_eq!(metadata.target_equity, dec!(6000));
80                assert_eq!(metadata.active_order_client_ids, vec!["liq-1".to_string()]);
81            }
82            _ => panic!("expected pre-liquidation"),
83        }
84    }
85
86    #[test]
87    fn test_recovery() {
88        let mut status = AccountLiquidationStatus::healthy(
89            test_wallet(),
90            MarginMode::Standard,
91            dec!(4000),
92            dec!(5000),
93            1000,
94        );
95
96        status.enter_pre_liquidation(partial_metadata(), 2_000);
97        status.update_health(dec!(10000), dec!(5000), 3_000);
98        status.recover_to_healthy(3_000);
99
100        assert!(status.state.is_healthy());
101        assert!(!status.needs_liquidation());
102    }
103
104    #[test]
105    fn test_liquidation_flow() {
106        let mut status = AccountLiquidationStatus::healthy(
107            test_wallet(),
108            MarginMode::Standard,
109            dec!(4000),
110            dec!(5000),
111            1000,
112        );
113
114        status.enter_pre_liquidation(partial_metadata(), 2_000);
115        assert!(status.state.is_pre_liquidation());
116
117        status.enter_liquidation(
118            FullLiquidationMetadata {
119                auction_id: "auction-123".to_string(),
120                request_id: Some("full-req-1".to_string()),
121                tx_hash: None,
122                started_at: 3_000,
123                chain_start_time: None,
124                margin_needed: dec!(2500),
125                stop_request_id: None,
126                stop_tx_hash: None,
127            },
128            3_000,
129        );
130        assert!(status.state.is_in_liquidation());
131        assert_eq!(status.state.auction_id(), Some("auction-123"));
132
133        status.complete_liquidation(
134            LiquidatedMetadata {
135                auction_id: "auction-123".to_string(),
136                completed_at: 4_000,
137                winner: Some(test_wallet()),
138                bonus: dec!(250),
139                tx_hash: Some("0xdead".to_string()),
140            },
141            4_000,
142        );
143        assert!(status.state.is_liquidated());
144        assert_eq!(status.state.auction_id(), Some("auction-123"));
145    }
146
147    #[test]
148    fn test_state_display_and_mode() {
149        assert_eq!(LiquidationState::Healthy.to_string(), "Healthy");
150        assert_eq!(LiquidationState::Healthy.as_str(), "Healthy");
151        assert_eq!(LiquidationState::Healthy.liquidation_mode(), None);
152
153        let pre_liq = LiquidationState::PreLiquidation(partial_metadata());
154        assert_eq!(pre_liq.as_str(), "PreLiquidation");
155        assert_eq!(pre_liq.liquidation_mode(), Some(LiquidationMode::Partial));
156        assert!(pre_liq.to_string().contains("target_equity=6000"));
157
158        let mut pending_full = partial_metadata();
159        pending_full.pending_full_auction_id = Some("auction-1".to_string());
160        pending_full.pending_full_request_id = Some("req-1".to_string());
161        let pending_state = LiquidationState::PreLiquidation(pending_full);
162        assert_eq!(
163            pending_state.liquidation_mode(),
164            Some(LiquidationMode::Full)
165        );
166        assert_eq!(pending_state.auction_id(), Some("auction-1"));
167    }
168
169    #[test]
170    fn test_serde() {
171        let state = LiquidationState::PreLiquidation(partial_metadata());
172        let json = sonic_rs::to_string(&state).unwrap();
173        assert!(json.contains("pre_liquidation"));
174
175        let parsed: LiquidationState = sonic_rs::from_str(&json).unwrap();
176        assert_eq!(parsed, state);
177    }
178
179    #[test]
180    fn test_partial_projection_change_detects_live_liquidation_deltas() {
181        let previous = LiquidationState::PreLiquidation(partial_metadata());
182        let mut current_metadata = partial_metadata();
183        current_metadata.bonus_bps = 250;
184        current_metadata.active_order_client_ids = vec!["liq-2".to_string()];
185        current_metadata.mm_shortfall = dec!(1250);
186        let current = LiquidationState::PreLiquidation(current_metadata);
187
188        assert!(has_material_projection_change(&previous, &current));
189    }
190}