1use 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
33pub struct LiquidationExecutor {
37 cache: Arc<LiquidationCache>,
39 portfolio_cache: Arc<PortfolioCache>,
41 event_sender: Option<mpsc::UnboundedSender<EngineMessage>>,
43 rsm_directive_publisher: Option<Arc<RsmDirectivePublisher>>,
45 rsm_signer: Option<Arc<dyn RsmSigner>>,
47 full_target_buffer_bps: u32,
49}
50
51impl LiquidationExecutor {
52 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 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 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 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, ¤t_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 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, ¤t_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 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
619fn 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
627fn 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 #[tokio::test]
679 async fn test_cache_transitions_for_liquidation() {
680 let cache = Arc::new(LiquidationCache::new());
681 let wallet = test_wallet();
682
683 cache
685 .init_if_absent(wallet, MarginMode::Standard, dec!(4000), dec!(5000), 1000)
686 .await;
687
688 let state = cache.get_state(&wallet).await;
690 assert!(state.is_healthy());
691
692 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 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 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 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 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 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}