Skip to main content

hypercall/liquidator/
executor.rs

1//! Liquidation executor service.
2//!
3//! Owns full-liquidation transaction submission and stop requests.
4
5use super::cache::LiquidationCache;
6#[cfg(test)]
7use super::state::LiquidatedMetadata;
8use super::state::{has_material_projection_change, LiquidationState};
9use crate::read_cache::portfolio::PortfolioCache;
10use crate::rsm_directive_publisher::RsmDirectivePublisher;
11use alloy::primitives::U256;
12use alloy::sol_types::SolValue;
13use anyhow::anyhow;
14use hypercall_signer::{encode_action_bytes, RsmSigner};
15use hypercall_types::directives::{
16    StartLiquidation, StopLiquidation, SYSTEM_ACTION_ID_START_LIQUIDATION,
17    SYSTEM_ACTION_ID_STOP_LIQUIDATION, SYSTEM_ACTION_VERSION,
18};
19use hypercall_types::WalletAddress;
20use hypercall_types::{EngineMessage, LiquidationStateMessage, LiquidationStateType};
21use metrics::counter;
22use rust_decimal::prelude::ToPrimitive;
23use rust_decimal::Decimal;
24use rust_decimal::RoundingStrategy;
25use rust_decimal_macros::dec;
26use std::sync::Arc;
27use tokio::sync::mpsc;
28use tracing::{info, warn};
29use uuid::Uuid;
30
31const ONCHAIN_USDC_SCALE_DP: u32 = 6;
32
33/// Liquidation executor service.
34///
35/// Orchestrates full-liquidation request submission and reconciliation metadata updates.
36pub struct LiquidationExecutor {
37    /// Liquidation state cache.
38    cache: Arc<LiquidationCache>,
39    /// Portfolio cache for getting account positions.
40    portfolio_cache: Arc<PortfolioCache>,
41    /// Event sender for publishing state changes.
42    event_sender: Option<mpsc::UnboundedSender<EngineMessage>>,
43    /// Shared publisher for signed RSM transaction requests.
44    rsm_directive_publisher: Option<Arc<RsmDirectivePublisher>>,
45    /// Shared backend RSM signer.
46    rsm_signer: Option<Arc<dyn RsmSigner>>,
47    /// Target health buffer after full liquidation, in basis points.
48    full_target_buffer_bps: u32,
49}
50
51impl LiquidationExecutor {
52    /// Create a new liquidation executor.
53    pub fn new(cache: Arc<LiquidationCache>, portfolio_cache: Arc<PortfolioCache>) -> Self {
54        Self {
55            cache,
56            portfolio_cache,
57            event_sender: None,
58            rsm_directive_publisher: None,
59            rsm_signer: None,
60            full_target_buffer_bps: 0,
61        }
62    }
63
64    /// Set the event sender for publishing state changes.
65    pub fn with_event_sender(mut self, sender: mpsc::UnboundedSender<EngineMessage>) -> Self {
66        self.event_sender = Some(sender);
67        self
68    }
69
70    pub fn with_rsm_directive_publisher(mut self, publisher: Arc<RsmDirectivePublisher>) -> Self {
71        self.rsm_directive_publisher = Some(publisher);
72        self
73    }
74
75    pub fn with_rsm_signer(mut self, rsm_signer: Arc<dyn RsmSigner>) -> Self {
76        self.rsm_signer = Some(rsm_signer);
77        self
78    }
79
80    pub fn with_full_target_buffer_bps(mut self, full_target_buffer_bps: u32) -> Self {
81        self.full_target_buffer_bps = full_target_buffer_bps;
82        self
83    }
84
85    /// Submit a `StartLiquidation` request for a wallet already in pre-liquidation.
86    ///
87    /// The account remains in `PreLiquidation` until the chain observer sees
88    /// `LiquidationStarted` on-chain.
89    pub async fn start_auction(&self, wallet: &WalletAddress) -> anyhow::Result<String> {
90        let rsm_signer = self
91            .rsm_signer
92            .as_ref()
93            .ok_or_else(|| anyhow::anyhow!("RsmSignerService not configured"))?;
94        let rsm_directive_publisher = self
95            .rsm_directive_publisher
96            .as_ref()
97            .ok_or_else(|| anyhow::anyhow!("RsmDirectivePublisher not configured"))?;
98        let status = self
99            .cache
100            .get_status(wallet)
101            .await
102            .ok_or_else(|| anyhow::anyhow!("wallet {} missing liquidation status", wallet))?;
103        let partial_metadata = match &status.state {
104            LiquidationState::PreLiquidation(metadata) => metadata,
105            other => {
106                anyhow::bail!(
107                    "full liquidation can only be requested from pre-liquidation, wallet {} is in {}",
108                    wallet,
109                    other.as_str()
110                );
111            }
112        };
113        if let Some(existing_request_id) = partial_metadata.pending_full_request_id.clone() {
114            let auction_id = partial_metadata
115                .pending_full_auction_id
116                .clone()
117                .ok_or_else(|| {
118                    anyhow::anyhow!(
119                        "wallet {} has pending full liquidation request {} without auction id",
120                        wallet,
121                        existing_request_id
122                    )
123                })?;
124            info!(
125                wallet = %wallet,
126                auction_id = %auction_id,
127                request_id = %existing_request_id,
128                "Skipping duplicate full liquidation request while one is already pending"
129            );
130            return Ok(auction_id);
131        }
132        let margin_needed = Self::compute_margin_needed(self.full_target_buffer_bps, &status);
133        if margin_needed <= Decimal::ZERO {
134            anyhow::bail!(
135                "refusing to start liquidation for {} with non-positive margin_needed={}",
136                wallet,
137                margin_needed
138            );
139        }
140
141        let auction_id = Uuid::now_v7().to_string();
142        let request_id = Uuid::now_v7().to_string();
143        let timestamp = get_timestamp_millis();
144
145        // Get positions for this wallet
146        let positions = self.get_position_symbols(wallet).await;
147
148        let action = encode_start_liquidation_action(status.equity, margin_needed)?;
149        let signed = rsm_signer
150            .sign(&request_id, wallet, &action)
151            .await
152            .map_err(|error| anyhow!("{}", error))?;
153        let mut current_status = self
154            .cache
155            .get_status(wallet)
156            .await
157            .ok_or_else(|| anyhow::anyhow!("wallet {} missing liquidation status", wallet))?;
158        let previous_state = current_status.state.clone();
159        if !Self::apply_start_claim(
160            &mut current_status,
161            wallet,
162            &auction_id,
163            &request_id,
164            margin_needed,
165            timestamp,
166        ) {
167            info!(
168                wallet = %wallet,
169                auction_id = %auction_id,
170                request_id = %request_id,
171                current_state = %current_status.state.as_str(),
172                "Skipped persisting stale full liquidation start claim because wallet state advanced during directive submission"
173            );
174            return Ok(auction_id);
175        }
176        self.cache.set_status(current_status.clone()).await;
177        self.emit_status_update(&previous_state, &current_status, timestamp);
178        if let Err(error) = rsm_directive_publisher
179            .publish(&request_id, wallet, signed)
180            .await
181        {
182            self.clear_start_claim_after_publish_failure(wallet, &request_id)
183                .await;
184            return Err(error);
185        }
186        info!(
187            wallet = %wallet,
188            auction_id = %auction_id,
189            request_id = %request_id,
190            positions = ?positions,
191            equity = %status.equity,
192            mm_required = %status.mm_required,
193            margin_needed = %margin_needed,
194            "Enqueued full liquidation start directive"
195        );
196        counter!("ht_liquidation_full_start_requests_total").increment(1);
197
198        info!(
199            "Enqueued liquidation auction {} for wallet {} with {} positions, request_id={}",
200            auction_id,
201            wallet,
202            positions.len(),
203            request_id
204        );
205
206        Ok(auction_id)
207    }
208
209    /// Submit a `StopLiquidation` request for an active full liquidation.
210    ///
211    /// The account remains in `InLiquidation` until the chain observer sees
212    /// `LiquidationStopped` on-chain.
213    pub async fn stop_auction(
214        &self,
215        wallet: &WalletAddress,
216        start_time: u64,
217    ) -> anyhow::Result<String> {
218        let rsm_signer = self
219            .rsm_signer
220            .as_ref()
221            .ok_or_else(|| anyhow::anyhow!("RsmSignerService not configured"))?;
222        let rsm_directive_publisher = self
223            .rsm_directive_publisher
224            .as_ref()
225            .ok_or_else(|| anyhow::anyhow!("RsmDirectivePublisher not configured"))?;
226        let status = self
227            .cache
228            .get_status(wallet)
229            .await
230            .ok_or_else(|| anyhow::anyhow!("wallet {} missing liquidation status", wallet))?;
231        let metadata = match &status.state {
232            LiquidationState::InLiquidation(metadata) => metadata,
233            other => {
234                anyhow::bail!(
235                    "stop liquidation requires in-liquidation state, wallet {} is in {}",
236                    wallet,
237                    other.as_str()
238                );
239            }
240        };
241
242        if let Some(existing_request_id) = metadata.stop_request_id.clone() {
243            info!(
244                wallet = %wallet,
245                auction_id = %metadata.auction_id,
246                request_id = %existing_request_id,
247                "Skipping duplicate stop liquidation request while one is already pending"
248            );
249            return Ok(existing_request_id);
250        }
251
252        let chain_start_time = metadata.chain_start_time.ok_or_else(|| {
253            anyhow::anyhow!(
254                "cannot submit stop liquidation for {} without observed chain_start_time",
255                wallet
256            )
257        })?;
258        if chain_start_time != start_time {
259            anyhow::bail!(
260                "stop liquidation start_time mismatch for {}: observed={}, requested={}",
261                wallet,
262                chain_start_time,
263                start_time
264            );
265        }
266
267        let auction_id = metadata.auction_id.clone();
268        let request_id = Uuid::now_v7().to_string();
269        let action = encode_stop_liquidation_action(start_time)?;
270        let signed = rsm_signer
271            .sign(&request_id, wallet, &action)
272            .await
273            .map_err(|error| anyhow!("{}", error))?;
274        let timestamp = get_timestamp_millis();
275        let mut current_status = self
276            .cache
277            .get_status(wallet)
278            .await
279            .ok_or_else(|| anyhow::anyhow!("wallet {} missing liquidation status", wallet))?;
280        let previous_state = current_status.state.clone();
281        if !Self::apply_stop_claim(&mut current_status, wallet, &request_id, timestamp) {
282            info!(
283                wallet = %wallet,
284                auction_id = %auction_id,
285                request_id = %request_id,
286                current_state = %current_status.state.as_str(),
287                "Skipped persisting stale stop liquidation claim because wallet state advanced during directive submission"
288            );
289            return Ok(request_id);
290        }
291        self.cache.set_status(current_status.clone()).await;
292        self.emit_status_update(&previous_state, &current_status, timestamp);
293        if let Err(error) = rsm_directive_publisher
294            .publish(&request_id, wallet, signed)
295            .await
296        {
297            self.clear_stop_claim_after_publish_failure(wallet, &request_id)
298                .await;
299            return Err(error);
300        }
301        counter!("ht_liquidation_full_stop_requests_total").increment(1);
302
303        info!(
304            wallet = %wallet,
305            auction_id = %auction_id,
306            request_id = %request_id,
307            start_time,
308            "Enqueued stop liquidation directive"
309        );
310
311        Ok(request_id)
312    }
313
314    /// Get position symbols for a wallet.
315    async fn get_position_symbols(&self, wallet: &WalletAddress) -> Vec<String> {
316        match self
317            .portfolio_cache
318            .get_service()
319            .get_portfolio_balance(wallet)
320            .await
321        {
322            Some(portfolio) => portfolio.positions.keys().cloned().collect(),
323            None => Vec::new(),
324        }
325    }
326
327    fn compute_margin_needed(
328        full_target_buffer_bps: u32,
329        status: &crate::liquidator::AccountLiquidationStatus,
330    ) -> Decimal {
331        let buffer_multiplier =
332            Decimal::from(10_000u64 + u64::from(full_target_buffer_bps)) / dec!(10000);
333        let target_equity = status.mm_required * buffer_multiplier;
334        let bonus = if status.equity.is_sign_negative() {
335            -status.equity
336        } else {
337            Decimal::ZERO
338        };
339        let margin_needed = target_equity - status.equity - bonus;
340        if margin_needed.is_sign_negative() {
341            Decimal::ZERO
342        } else {
343            margin_needed
344        }
345    }
346
347    fn apply_start_claim(
348        status: &mut crate::liquidator::AccountLiquidationStatus,
349        wallet: &WalletAddress,
350        auction_id: &str,
351        request_id: &str,
352        margin_needed: Decimal,
353        timestamp: u64,
354    ) -> bool {
355        let LiquidationState::PreLiquidation(metadata) = &mut status.state else {
356            warn!(
357                wallet = %wallet,
358                current_state = %status.state.as_str(),
359                "Wallet left pre-liquidation before full start claim persistence, preserving newer state"
360            );
361            return false;
362        };
363
364        metadata.pending_full_auction_id = Some(auction_id.to_string());
365        metadata.pending_full_request_id = Some(request_id.to_string());
366        metadata.pending_full_tx_hash = None;
367        metadata.pending_full_margin_needed = Some(margin_needed);
368        status.updated_at = timestamp;
369        true
370    }
371
372    fn apply_stop_claim(
373        status: &mut crate::liquidator::AccountLiquidationStatus,
374        wallet: &WalletAddress,
375        request_id: &str,
376        timestamp: u64,
377    ) -> bool {
378        let LiquidationState::InLiquidation(metadata) = &mut status.state else {
379            warn!(
380                wallet = %wallet,
381                current_state = %status.state.as_str(),
382                "Wallet left in-liquidation before stop claim persistence, preserving newer state"
383            );
384            return false;
385        };
386
387        metadata.stop_request_id = Some(request_id.to_string());
388        metadata.stop_tx_hash = None;
389        status.updated_at = timestamp;
390        true
391    }
392
393    async fn clear_start_claim_after_publish_failure(
394        &self,
395        wallet: &WalletAddress,
396        request_id: &str,
397    ) {
398        let timestamp = get_timestamp_millis();
399        let Some(mut status) = self.cache.get_status(wallet).await else {
400            warn!(
401                wallet = %wallet,
402                request_id,
403                "Cannot clear failed full liquidation start claim because wallet status is missing"
404            );
405            return;
406        };
407        let previous_state = status.state.clone();
408        if Self::clear_start_claim(&mut status, wallet, request_id, timestamp) {
409            self.cache.set_status(status.clone()).await;
410            self.emit_status_update(&previous_state, &status, timestamp);
411        }
412    }
413
414    async fn clear_stop_claim_after_publish_failure(
415        &self,
416        wallet: &WalletAddress,
417        request_id: &str,
418    ) {
419        let timestamp = get_timestamp_millis();
420        let Some(mut status) = self.cache.get_status(wallet).await else {
421            warn!(
422                wallet = %wallet,
423                request_id,
424                "Cannot clear failed stop liquidation claim because wallet status is missing"
425            );
426            return;
427        };
428        let previous_state = status.state.clone();
429        if Self::clear_stop_claim(&mut status, wallet, request_id, timestamp) {
430            self.cache.set_status(status.clone()).await;
431            self.emit_status_update(&previous_state, &status, timestamp);
432        }
433    }
434
435    fn clear_start_claim(
436        status: &mut crate::liquidator::AccountLiquidationStatus,
437        wallet: &WalletAddress,
438        request_id: &str,
439        timestamp: u64,
440    ) -> bool {
441        let LiquidationState::PreLiquidation(metadata) = &mut status.state else {
442            warn!(
443                wallet = %wallet,
444                request_id,
445                current_state = %status.state.as_str(),
446                "Wallet left pre-liquidation before failed full start claim cleanup, preserving newer state"
447            );
448            return false;
449        };
450
451        if metadata.pending_full_request_id.as_deref() != Some(request_id) {
452            warn!(
453                wallet = %wallet,
454                request_id,
455                current_request_id = ?metadata.pending_full_request_id,
456                "Skipping failed full start claim cleanup because pending request changed"
457            );
458            return false;
459        }
460
461        metadata.pending_full_auction_id = None;
462        metadata.pending_full_request_id = None;
463        metadata.pending_full_tx_hash = None;
464        metadata.pending_full_margin_needed = None;
465        status.updated_at = timestamp;
466        true
467    }
468
469    fn clear_stop_claim(
470        status: &mut crate::liquidator::AccountLiquidationStatus,
471        wallet: &WalletAddress,
472        request_id: &str,
473        timestamp: u64,
474    ) -> bool {
475        let LiquidationState::InLiquidation(metadata) = &mut status.state else {
476            warn!(
477                wallet = %wallet,
478                request_id,
479                current_state = %status.state.as_str(),
480                "Wallet left in-liquidation before failed stop claim cleanup, preserving newer state"
481            );
482            return false;
483        };
484
485        if metadata.stop_request_id.as_deref() != Some(request_id) {
486            warn!(
487                wallet = %wallet,
488                request_id,
489                current_request_id = ?metadata.stop_request_id,
490                "Skipping failed stop claim cleanup because pending request changed"
491            );
492            return false;
493        }
494
495        metadata.stop_request_id = None;
496        metadata.stop_tx_hash = None;
497        status.updated_at = timestamp;
498        true
499    }
500
501    fn emit_status_update(
502        &self,
503        previous_state: &LiquidationState,
504        status: &crate::liquidator::AccountLiquidationStatus,
505        timestamp: u64,
506    ) {
507        let Some(sender) = &self.event_sender else {
508            return;
509        };
510
511        let msg = LiquidationStateMessage {
512            wallet: status.wallet,
513            previous_state: liquidation_state_to_type(previous_state),
514            new_state: liquidation_state_to_type(&status.state),
515            previous_liquidation_mode: previous_state
516                .liquidation_mode()
517                .map(|mode| mode.as_str().to_string()),
518            liquidation_mode: status
519                .liquidation_mode()
520                .map(|mode| mode.as_str().to_string()),
521            margin_mode: status.margin_mode.as_str().to_string(),
522            equity: status.equity,
523            mm_required: status.mm_required,
524            maintenance_margin: status.maintenance_margin,
525            shortfall: status.shortfall(),
526            previous_auction_id: previous_state.auction_id().map(str::to_string),
527            projection_changed: has_material_projection_change(previous_state, &status.state),
528            auction_id: status
529                .state
530                .auction_id()
531                .map(ToOwned::to_owned)
532                .or_else(|| previous_state.auction_id().map(ToOwned::to_owned)),
533            status: status.clone(),
534            timestamp,
535        };
536
537        if let Err(e) = sender.send(EngineMessage::LiquidationStateChange(msg)) {
538            warn!("Failed to send liquidation state change event: {}", e);
539        }
540    }
541}
542
543fn encode_start_liquidation_action(
544    equity: Decimal,
545    margin_needed: Decimal,
546) -> anyhow::Result<Vec<u8>> {
547    if margin_needed <= Decimal::ZERO {
548        anyhow::bail!(
549            "refusing to submit StartLiquidation with non-positive margin_needed={}",
550            margin_needed
551        );
552    }
553
554    let equity_units = scaled_signed_usdc_units(equity, "equity")?;
555    let margin_needed_units = scaled_unsigned_usdc_units(margin_needed, "margin_needed")?;
556    if margin_needed_units == 0 {
557        anyhow::bail!(
558            "margin_needed quantized to zero after USDC scaling (raw={})",
559            margin_needed
560        );
561    }
562
563    encode_action_bytes(
564        SYSTEM_ACTION_VERSION,
565        SYSTEM_ACTION_ID_START_LIQUIDATION,
566        &StartLiquidation {
567            equity: alloy::primitives::I256::try_from(equity_units).map_err(|error| {
568                anyhow!("equity {} exceeds I256 range: {}", equity_units, error)
569            })?,
570            marginNeeded: U256::from(margin_needed_units),
571        }
572        .abi_encode(),
573    )
574    .map_err(|error| anyhow!("{}", error))
575}
576
577fn encode_stop_liquidation_action(start_time: u64) -> anyhow::Result<Vec<u8>> {
578    if start_time == 0 {
579        anyhow::bail!("refusing to submit StopLiquidation with start_time=0");
580    }
581
582    encode_action_bytes(
583        SYSTEM_ACTION_VERSION,
584        SYSTEM_ACTION_ID_STOP_LIQUIDATION,
585        &StopLiquidation {
586            startTime: U256::from(start_time),
587        }
588        .abi_encode(),
589    )
590    .map_err(|error| anyhow!("{}", error))
591}
592
593fn quantize_onchain_usdc_units(amount: Decimal) -> Decimal {
594    amount.round_dp_with_strategy(
595        ONCHAIN_USDC_SCALE_DP,
596        RoundingStrategy::MidpointAwayFromZero,
597    )
598}
599
600fn scaled_unsigned_usdc_units(amount: Decimal, field: &str) -> anyhow::Result<u128> {
601    if amount < Decimal::ZERO {
602        anyhow::bail!("{} must be non-negative, got {}", field, amount);
603    }
604    let scaled =
605        quantize_onchain_usdc_units(amount) * hypercall_types::CONTRACT_UNIT_MULTIPLIER_DECIMAL;
606    scaled
607        .to_u128()
608        .ok_or_else(|| anyhow!("{}={} exceeds u128 range after scaling", field, amount))
609}
610
611fn scaled_signed_usdc_units(amount: Decimal, field: &str) -> anyhow::Result<i128> {
612    let scaled =
613        quantize_onchain_usdc_units(amount) * hypercall_types::CONTRACT_UNIT_MULTIPLIER_DECIMAL;
614    scaled
615        .to_i128()
616        .ok_or_else(|| anyhow!("{}={} exceeds i128 range after scaling", field, amount))
617}
618
619/// Get current timestamp in milliseconds.
620fn get_timestamp_millis() -> u64 {
621    std::time::SystemTime::now()
622        .duration_since(std::time::UNIX_EPOCH)
623        .unwrap_or_default()
624        .as_millis() as u64
625}
626
627/// Convert LiquidationState to LiquidationStateType for messages.
628fn liquidation_state_to_type(state: &LiquidationState) -> LiquidationStateType {
629    match state {
630        LiquidationState::Healthy => LiquidationStateType::Healthy,
631        LiquidationState::PreLiquidation(..) => LiquidationStateType::PreLiquidation,
632        LiquidationState::InLiquidation(..) => LiquidationStateType::InLiquidation,
633        LiquidationState::Liquidated(..) => LiquidationStateType::Liquidated,
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use crate::liquidator::{
641        AccountLiquidationStatus, FullLiquidationMetadata, LiquidationCache,
642        PartialLiquidationMetadata,
643    };
644    use crate::rsm::MarginMode;
645    use rust_decimal_macros::dec;
646
647    fn test_wallet() -> WalletAddress {
648        "0x1234567890123456789012345678901234567890"
649            .parse()
650            .unwrap()
651    }
652
653    fn test_liquidator() -> WalletAddress {
654        "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
655            .parse()
656            .unwrap()
657    }
658
659    fn partial_metadata() -> PartialLiquidationMetadata {
660        PartialLiquidationMetadata {
661            entered_at: 2_000,
662            mm_shortfall: dec!(1000),
663            target_equity: dec!(5_500),
664            escalation_deadline: 62_000,
665            last_reprice_at: None,
666            active_order_request_ids: vec![],
667            active_order_client_ids: vec![],
668            bonus_bps: 0,
669            pending_full_auction_id: None,
670            pending_full_request_id: None,
671            pending_full_tx_hash: None,
672            pending_full_margin_needed: None,
673        }
674    }
675
676    /// Test cache state transitions without PortfolioCache (simpler test setup).
677    /// The full integration with PortfolioCache is tested elsewhere.
678    #[tokio::test]
679    async fn test_cache_transitions_for_liquidation() {
680        let cache = Arc::new(LiquidationCache::new());
681        let wallet = test_wallet();
682
683        // Initialize wallet in cache
684        cache
685            .init_if_absent(wallet, MarginMode::Standard, dec!(4000), dec!(5000), 1000)
686            .await;
687
688        // Verify starts healthy
689        let state = cache.get_state(&wallet).await;
690        assert!(state.is_healthy());
691
692        // Enter pre-liquidation
693        cache
694            .enter_pre_liquidation(&wallet, partial_metadata(), 2000)
695            .await;
696        let state = cache.get_state(&wallet).await;
697        assert!(state.is_pre_liquidation());
698
699        // Enter liquidation (simulates what executor.start_auction does)
700        let auction_id = "test-auction-123".to_string();
701        cache
702            .enter_liquidation(
703                &wallet,
704                FullLiquidationMetadata {
705                    auction_id: auction_id.clone(),
706                    request_id: None,
707                    tx_hash: None,
708                    started_at: 3_000,
709                    chain_start_time: None,
710                    margin_needed: dec!(1000),
711                    stop_request_id: None,
712                    stop_tx_hash: None,
713                },
714                3000,
715            )
716            .await;
717        let state = cache.get_state(&wallet).await;
718        assert!(state.is_in_liquidation());
719        assert_eq!(state.auction_id(), Some(auction_id.as_str()));
720
721        // Complete liquidation (simulates what executor.settle_auction does)
722        cache
723            .complete_liquidation(
724                &wallet,
725                LiquidatedMetadata {
726                    auction_id,
727                    completed_at: 4_000,
728                    winner: Some(test_liquidator()),
729                    bonus: Decimal::ZERO,
730                    tx_hash: None,
731                },
732                4000,
733            )
734            .await;
735        let state = cache.get_state(&wallet).await;
736        assert!(state.is_liquidated());
737    }
738
739    #[tokio::test]
740    async fn test_settle_and_complete_logs_onchain_calls() {
741        let cache = Arc::new(LiquidationCache::new());
742        let wallet = test_wallet();
743        let liquidator = test_liquidator();
744
745        // Initialize wallet in InLiquidation state
746        cache
747            .init_if_absent(wallet, MarginMode::Standard, dec!(4000), dec!(5000), 1000)
748            .await;
749        cache
750            .enter_liquidation(
751                &wallet,
752                FullLiquidationMetadata {
753                    auction_id: "auction-123".to_string(),
754                    request_id: None,
755                    tx_hash: None,
756                    started_at: 2_000,
757                    chain_start_time: None,
758                    margin_needed: dec!(1000),
759                    stop_request_id: None,
760                    stop_tx_hash: None,
761                },
762                2000,
763            )
764            .await;
765
766        // Settle auction using the cache method
767        let result = cache
768            .settle_and_complete_liquidation(&wallet, "auction-123", &liquidator, dec!(3500), 3000)
769            .await;
770
771        assert!(result.is_some());
772
773        // Verify state is now Liquidated
774        let state = cache.get_state(&wallet).await;
775        assert!(state.is_liquidated());
776    }
777
778    #[test]
779    fn test_compute_margin_needed_for_solvent_account() {
780        let status = AccountLiquidationStatus::healthy(
781            test_wallet(),
782            MarginMode::Standard,
783            dec!(90),
784            dec!(100),
785            1_000,
786        );
787
788        let margin_needed = LiquidationExecutor::compute_margin_needed(500, &status);
789
790        assert_eq!(margin_needed, dec!(15));
791    }
792
793    #[test]
794    fn test_compute_margin_needed_for_insolvent_account() {
795        let status = AccountLiquidationStatus::healthy(
796            test_wallet(),
797            MarginMode::Standard,
798            dec!(-10),
799            dec!(100),
800            1_000,
801        );
802
803        let margin_needed = LiquidationExecutor::compute_margin_needed(500, &status);
804
805        assert_eq!(margin_needed, dec!(105));
806    }
807
808    #[test]
809    fn test_apply_start_claim_updates_pre_liquidation_status() {
810        let wallet = test_wallet();
811        let mut status = AccountLiquidationStatus::healthy(
812            wallet,
813            MarginMode::Standard,
814            dec!(90),
815            dec!(100),
816            1_000,
817        );
818        status.enter_pre_liquidation(partial_metadata(), 2_000);
819
820        let applied = LiquidationExecutor::apply_start_claim(
821            &mut status,
822            &wallet,
823            "auction-123",
824            "request-123",
825            dec!(15),
826            3_000,
827        );
828
829        assert!(applied);
830        match &status.state {
831            LiquidationState::PreLiquidation(metadata) => {
832                assert_eq!(
833                    metadata.pending_full_auction_id.as_deref(),
834                    Some("auction-123")
835                );
836                assert_eq!(
837                    metadata.pending_full_request_id.as_deref(),
838                    Some("request-123")
839                );
840                assert_eq!(metadata.pending_full_margin_needed, Some(dec!(15)));
841            }
842            other => panic!("expected pre-liquidation status, got {}", other.as_str()),
843        }
844        assert_eq!(status.updated_at, 3_000);
845    }
846
847    #[test]
848    fn test_apply_start_claim_preserves_newer_liquidation_state() {
849        let wallet = test_wallet();
850        let mut status = AccountLiquidationStatus::healthy(
851            wallet,
852            MarginMode::Standard,
853            dec!(90),
854            dec!(100),
855            1_000,
856        );
857        status.enter_liquidation(
858            FullLiquidationMetadata {
859                auction_id: "auction-live".to_string(),
860                request_id: Some("existing-request".to_string()),
861                tx_hash: Some("0xstart".to_string()),
862                started_at: 2_000,
863                chain_start_time: Some(55),
864                margin_needed: dec!(20),
865                stop_request_id: None,
866                stop_tx_hash: None,
867            },
868            2_000,
869        );
870        let original = status.clone();
871
872        let applied = LiquidationExecutor::apply_start_claim(
873            &mut status,
874            &wallet,
875            "auction-123",
876            "request-123",
877            dec!(15),
878            3_000,
879        );
880
881        assert!(!applied);
882        assert_eq!(status.state, original.state);
883        assert_eq!(status.updated_at, original.updated_at);
884    }
885
886    #[test]
887    fn test_clear_start_claim_removes_only_matching_pending_request() {
888        let wallet = test_wallet();
889        let mut status = AccountLiquidationStatus::healthy(
890            wallet,
891            MarginMode::Standard,
892            dec!(90),
893            dec!(100),
894            1_000,
895        );
896        status.enter_pre_liquidation(partial_metadata(), 2_000);
897        assert!(LiquidationExecutor::apply_start_claim(
898            &mut status,
899            &wallet,
900            "auction-123",
901            "request-123",
902            dec!(15),
903            3_000,
904        ));
905
906        assert!(!LiquidationExecutor::clear_start_claim(
907            &mut status,
908            &wallet,
909            "other-request",
910            4_000,
911        ));
912        match &status.state {
913            LiquidationState::PreLiquidation(metadata) => {
914                assert_eq!(
915                    metadata.pending_full_request_id.as_deref(),
916                    Some("request-123")
917                );
918            }
919            other => panic!("expected pre-liquidation status, got {}", other.as_str()),
920        }
921
922        assert!(LiquidationExecutor::clear_start_claim(
923            &mut status,
924            &wallet,
925            "request-123",
926            5_000,
927        ));
928        match &status.state {
929            LiquidationState::PreLiquidation(metadata) => {
930                assert_eq!(metadata.pending_full_auction_id, None);
931                assert_eq!(metadata.pending_full_request_id, None);
932                assert_eq!(metadata.pending_full_tx_hash, None);
933                assert_eq!(metadata.pending_full_margin_needed, None);
934            }
935            other => panic!("expected pre-liquidation status, got {}", other.as_str()),
936        }
937        assert_eq!(status.updated_at, 5_000);
938    }
939
940    #[test]
941    fn test_apply_stop_claim_updates_in_liquidation_status() {
942        let wallet = test_wallet();
943        let mut status = AccountLiquidationStatus::healthy(
944            wallet,
945            MarginMode::Standard,
946            dec!(90),
947            dec!(100),
948            1_000,
949        );
950        status.enter_liquidation(
951            FullLiquidationMetadata {
952                auction_id: "auction-live".to_string(),
953                request_id: Some("existing-request".to_string()),
954                tx_hash: Some("0xstart".to_string()),
955                started_at: 2_000,
956                chain_start_time: Some(55),
957                margin_needed: dec!(20),
958                stop_request_id: None,
959                stop_tx_hash: None,
960            },
961            2_000,
962        );
963
964        let applied =
965            LiquidationExecutor::apply_stop_claim(&mut status, &wallet, "stop-request", 3_000);
966
967        assert!(applied);
968        match &status.state {
969            LiquidationState::InLiquidation(metadata) => {
970                assert_eq!(metadata.stop_request_id.as_deref(), Some("stop-request"));
971                assert_eq!(metadata.stop_tx_hash, None);
972            }
973            other => panic!("expected in-liquidation status, got {}", other.as_str()),
974        }
975        assert_eq!(status.updated_at, 3_000);
976    }
977
978    #[test]
979    fn test_clear_stop_claim_removes_only_matching_pending_request() {
980        let wallet = test_wallet();
981        let mut status = AccountLiquidationStatus::healthy(
982            wallet,
983            MarginMode::Standard,
984            dec!(90),
985            dec!(100),
986            1_000,
987        );
988        status.enter_liquidation(
989            FullLiquidationMetadata {
990                auction_id: "auction-live".to_string(),
991                request_id: Some("existing-request".to_string()),
992                tx_hash: Some("0xstart".to_string()),
993                started_at: 2_000,
994                chain_start_time: Some(55),
995                margin_needed: dec!(20),
996                stop_request_id: None,
997                stop_tx_hash: None,
998            },
999            2_000,
1000        );
1001        assert!(LiquidationExecutor::apply_stop_claim(
1002            &mut status,
1003            &wallet,
1004            "stop-request",
1005            3_000,
1006        ));
1007
1008        assert!(!LiquidationExecutor::clear_stop_claim(
1009            &mut status,
1010            &wallet,
1011            "other-request",
1012            4_000,
1013        ));
1014        match &status.state {
1015            LiquidationState::InLiquidation(metadata) => {
1016                assert_eq!(metadata.stop_request_id.as_deref(), Some("stop-request"));
1017            }
1018            other => panic!("expected in-liquidation status, got {}", other.as_str()),
1019        }
1020
1021        assert!(LiquidationExecutor::clear_stop_claim(
1022            &mut status,
1023            &wallet,
1024            "stop-request",
1025            5_000,
1026        ));
1027        match &status.state {
1028            LiquidationState::InLiquidation(metadata) => {
1029                assert_eq!(metadata.stop_request_id, None);
1030                assert_eq!(metadata.stop_tx_hash, None);
1031            }
1032            other => panic!("expected in-liquidation status, got {}", other.as_str()),
1033        }
1034        assert_eq!(status.updated_at, 5_000);
1035    }
1036
1037    #[test]
1038    fn test_apply_stop_claim_preserves_terminal_state() {
1039        let wallet = test_wallet();
1040        let mut status = AccountLiquidationStatus::healthy(
1041            wallet,
1042            MarginMode::Standard,
1043            dec!(90),
1044            dec!(100),
1045            1_000,
1046        );
1047        status.complete_liquidation(
1048            LiquidatedMetadata {
1049                auction_id: "auction-live".to_string(),
1050                completed_at: 2_500,
1051                winner: Some(test_liquidator()),
1052                bonus: Decimal::ZERO,
1053                tx_hash: Some("0xresolve".to_string()),
1054            },
1055            2_500,
1056        );
1057        let original = status.clone();
1058
1059        let applied =
1060            LiquidationExecutor::apply_stop_claim(&mut status, &wallet, "stop-request", 3_000);
1061
1062        assert!(!applied);
1063        assert_eq!(status.state, original.state);
1064        assert_eq!(status.updated_at, original.updated_at);
1065    }
1066}