1use crate::sonic_json::SonicJson;
6use axum::{
7 extract::{Path, Query, State},
8 http::StatusCode,
9};
10use hypercall_db::LiquidationReader;
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize, Serializer};
13use utoipa::{IntoParams, ToSchema};
14
15use super::AppState;
16use crate::models::Pagination;
17use hypercall_types::WalletAddress;
18
19#[derive(Debug, Deserialize, IntoParams)]
21pub struct LiquidationStatusQuery {
22 #[param(example = "0x1234567890123456789012345678901234567890")]
24 pub wallet: String,
25}
26
27#[derive(Debug, Deserialize, IntoParams)]
29pub struct LiquidationHistoryQuery {
30 #[param(example = "0x1234567890123456789012345678901234567890")]
32 pub wallet: String,
33 #[param(example = 20)]
35 pub limit: Option<i64>,
36 #[param(example = 0)]
38 pub offset: Option<i64>,
39}
40
41#[derive(Debug, Serialize, ToSchema)]
43pub struct LiquidationStatusResponse {
44 pub success: bool,
46 pub data: Option<LiquidationStatusData>,
48 pub error: Option<String>,
50}
51
52#[derive(Debug, Serialize, ToSchema)]
54pub struct LiquidationStatusData {
55 pub wallet: String,
57 pub state: String,
59 pub liquidation_mode: Option<String>,
61 pub margin_mode: String,
63 #[schema(value_type = String)]
65 #[serde(serialize_with = "serialize_decimal_string")]
66 pub equity: Decimal,
67 #[schema(value_type = String)]
69 #[serde(serialize_with = "serialize_decimal_string")]
70 pub mm_required: Decimal,
71 #[schema(value_type = String)]
73 #[serde(serialize_with = "serialize_decimal_string")]
74 pub maintenance_margin: Decimal,
75 #[schema(value_type = String)]
77 #[serde(serialize_with = "serialize_decimal_string")]
78 pub shortfall: Decimal,
79 pub partial_liquidation: Option<PartialLiquidationStatusData>,
81 pub full_liquidation: Option<FullLiquidationStatusData>,
83}
84
85#[derive(Debug, Serialize, ToSchema)]
86pub struct PartialLiquidationStatusData {
87 pub entered_at: i64,
88 #[schema(value_type = String)]
89 #[serde(serialize_with = "serialize_decimal_string")]
90 pub target_equity: Decimal,
91 #[schema(value_type = String)]
92 #[serde(serialize_with = "serialize_decimal_string")]
93 pub mm_shortfall: Decimal,
94 pub escalation_deadline: i64,
95 pub last_reprice_at: Option<i64>,
96 pub active_order_request_ids: Vec<String>,
97 pub active_order_client_ids: Vec<String>,
98 pub bonus_bps: i32,
99 pub pending_full_auction_id: Option<String>,
100 pub pending_full_request_id: Option<String>,
101 pub pending_full_tx_hash: Option<String>,
102 #[schema(value_type = Option<String>)]
103 #[serde(serialize_with = "serialize_optional_decimal_string")]
104 pub pending_full_margin_needed: Option<Decimal>,
105}
106
107#[derive(Debug, Serialize, ToSchema)]
108pub struct FullLiquidationStatusData {
109 pub auction_id: Option<String>,
110 pub request_id: Option<String>,
111 pub tx_hash: Option<String>,
112 pub started_at: Option<i64>,
113 pub chain_start_time: Option<i64>,
114 #[schema(value_type = Option<String>)]
115 #[serde(serialize_with = "serialize_optional_decimal_string")]
116 pub margin_needed: Option<Decimal>,
117 pub stop_request_id: Option<String>,
118 pub stop_tx_hash: Option<String>,
119 pub liquidated_at: Option<i64>,
120 pub winner: Option<String>,
121 #[schema(value_type = Option<String>)]
122 #[serde(serialize_with = "serialize_optional_decimal_string")]
123 pub bonus: Option<Decimal>,
124 pub resolution_tx_hash: Option<String>,
125}
126
127#[derive(Debug, Serialize, ToSchema)]
129pub struct LiquidationHistoryResponse {
130 pub success: bool,
132 pub data: Vec<LiquidationHistoryEntry>,
134 pub pagination: Pagination,
136 pub error: Option<String>,
138}
139
140#[derive(Debug, Serialize, ToSchema)]
142pub struct LiquidationHistoryEntry {
143 pub id: i64,
145 pub wallet: String,
147 pub previous_state: String,
149 pub new_state: String,
151 #[schema(value_type = String)]
153 #[serde(serialize_with = "serialize_decimal_string")]
154 pub equity: Decimal,
155 #[schema(value_type = String)]
157 #[serde(serialize_with = "serialize_decimal_string")]
158 pub mm_required: Decimal,
159 #[schema(value_type = String)]
161 #[serde(serialize_with = "serialize_decimal_string")]
162 pub maintenance_margin: Decimal,
163 #[schema(value_type = String)]
165 #[serde(serialize_with = "serialize_decimal_string")]
166 pub shortfall: Decimal,
167 pub liquidation_mode: Option<String>,
169 pub auction_id: Option<String>,
171 pub request_id: Option<String>,
173 pub tx_hash: Option<String>,
175 #[schema(value_type = Option<String>)]
177 #[serde(serialize_with = "serialize_optional_decimal_string")]
178 pub margin_needed: Option<Decimal>,
179 pub winner_address: Option<String>,
181 #[schema(value_type = Option<String>)]
183 #[serde(serialize_with = "serialize_optional_decimal_string")]
184 pub bonus: Option<Decimal>,
185 #[schema(value_type = Object)]
187 pub details: sonic_rs::Value,
188 pub timestamp: i64,
190}
191
192#[derive(Debug, Serialize, ToSchema)]
194pub struct LiquidationAuctionResponse {
195 pub success: bool,
197 pub data: Option<LiquidationAuctionData>,
199 pub error: Option<String>,
201}
202
203#[derive(Debug, Serialize, ToSchema)]
205pub struct LiquidationAuctionData {
206 pub auction_id: String,
208 pub wallet: String,
210 pub status: String,
212 #[schema(value_type = Object)]
214 pub positions: sonic_rs::Value,
215 #[schema(value_type = String)]
217 #[serde(serialize_with = "serialize_decimal_string")]
218 pub equity_at_start: Decimal,
219 #[schema(value_type = String)]
221 #[serde(serialize_with = "serialize_decimal_string")]
222 pub mm_shortfall_at_start: Decimal,
223 #[schema(value_type = Option<String>)]
225 #[serde(serialize_with = "serialize_optional_decimal_string")]
226 pub target_equity: Option<Decimal>,
227 pub request_id: Option<String>,
229 pub tx_hash: Option<String>,
231 pub started_at: i64,
233 pub chain_start_time: Option<i64>,
235 #[schema(value_type = Option<String>)]
237 #[serde(serialize_with = "serialize_optional_decimal_string")]
238 pub margin_needed: Option<Decimal>,
239 pub stop_request_id: Option<String>,
241 pub stop_tx_hash: Option<String>,
243 pub completed_at: Option<i64>,
245 pub liquidator_address: Option<String>,
247 #[schema(value_type = Option<String>)]
249 #[serde(serialize_with = "serialize_optional_decimal_string")]
250 pub bonus: Option<Decimal>,
251 #[schema(value_type = Option<String>)]
253 #[serde(serialize_with = "serialize_optional_decimal_string")]
254 pub settlement_value: Option<Decimal>,
255 pub last_observed_block: Option<i64>,
257}
258
259fn required_i64(value: Option<i64>, field: &str) -> anyhow::Result<i64> {
260 value.ok_or_else(|| anyhow::anyhow!("missing liquidation field '{}'", field))
261}
262
263fn required_decimal(value: Option<Decimal>, field: &str) -> anyhow::Result<Decimal> {
264 value.ok_or_else(|| anyhow::anyhow!("missing liquidation field '{}'", field))
265}
266
267fn required_i32(value: Option<i32>, field: &str) -> anyhow::Result<i32> {
268 value.ok_or_else(|| anyhow::anyhow!("missing liquidation field '{}'", field))
269}
270
271fn serialize_decimal_string<S>(value: &Decimal, serializer: S) -> Result<S::Ok, S::Error>
272where
273 S: Serializer,
274{
275 serializer.serialize_str(&value.to_string())
276}
277
278fn serialize_optional_decimal_string<S>(
279 value: &Option<Decimal>,
280 serializer: S,
281) -> Result<S::Ok, S::Error>
282where
283 S: Serializer,
284{
285 match value {
286 Some(value) => serializer.serialize_some(&value.to_string()),
287 None => serializer.serialize_none(),
288 }
289}
290
291fn parse_json_string_array(value: Option<&serde_json::Value>) -> Vec<String> {
292 value
293 .and_then(|entry| entry.as_array())
294 .map(|entries| {
295 entries
296 .iter()
297 .filter_map(|entry| entry.as_str().map(ToOwned::to_owned))
298 .collect()
299 })
300 .unwrap_or_default()
301}
302
303fn jsonb_to_sonic(value: &serde_json::Value, field: &str) -> anyhow::Result<sonic_rs::Value> {
304 sonic_rs::from_str(&value.to_string())
305 .map_err(|e| anyhow::anyhow!("failed to decode {} JSON payload: {}", field, e))
306}
307
308fn present_auction_status(status: &str) -> &str {
309 match status {
310 "completed" => "resolved",
311 "cancelled" => "stopped",
312 other => other,
313 }
314}
315
316fn build_liquidation_status_data(
317 record: hypercall_db::LiquidationStateRecord,
318) -> anyhow::Result<LiquidationStatusData> {
319 let state_name = record.state.clone();
320 let partial_liquidation = if state_name == "pre_liquidation" {
321 Some(PartialLiquidationStatusData {
322 entered_at: required_i64(record.entered_pre_liq_at, "entered_pre_liq_at")?,
323 target_equity: required_decimal(record.target_equity, "target_equity")?,
324 mm_shortfall: record
325 .mm_shortfall
326 .unwrap_or_else(|| (record.mm_required - record.equity).max(Decimal::ZERO)),
327 escalation_deadline: required_i64(record.escalation_deadline, "escalation_deadline")?,
328 last_reprice_at: record.last_reprice_at,
329 active_order_request_ids: parse_json_string_array(
330 record.partial_order_request_ids.as_ref(),
331 ),
332 active_order_client_ids: parse_json_string_array(
333 record.partial_order_client_ids.as_ref(),
334 ),
335 bonus_bps: required_i32(record.partial_bonus_bps, "partial_bonus_bps")?,
336 pending_full_auction_id: record.auction_id.clone(),
337 pending_full_request_id: record.request_id.clone(),
338 pending_full_tx_hash: record.tx_hash.clone(),
339 pending_full_margin_needed: record.margin_needed,
340 })
341 } else {
342 None
343 };
344
345 let full_liquidation = if state_name == "in_liquidation" || state_name == "liquidated" {
346 Some(FullLiquidationStatusData {
347 auction_id: record.auction_id.clone(),
348 request_id: record.request_id.clone(),
349 tx_hash: record.tx_hash.clone(),
350 started_at: record.auction_started_at,
351 chain_start_time: record.chain_start_time,
352 margin_needed: record.margin_needed,
353 stop_request_id: record.stop_request_id.clone(),
354 stop_tx_hash: record.stop_tx_hash.clone(),
355 liquidated_at: record.liquidated_at,
356 winner: record.resolved_winner.map(|winner| winner.to_string()),
357 bonus: record.resolved_bonus,
358 resolution_tx_hash: record.resolution_tx_hash.clone(),
359 })
360 } else {
361 None
362 };
363
364 Ok(LiquidationStatusData {
365 wallet: record.wallet_address.to_string(),
366 state: state_name,
367 liquidation_mode: record.liquidation_mode,
368 margin_mode: record.margin_mode,
369 equity: record.equity,
370 mm_required: record.mm_required,
371 maintenance_margin: record.maintenance_margin,
372 shortfall: (record.mm_required - record.equity).max(Decimal::ZERO),
373 partial_liquidation,
374 full_liquidation,
375 })
376}
377
378#[utoipa::path(
380 get,
381 path = "/liquidation/status",
382 params(LiquidationStatusQuery),
383 responses(
384 (status = 200, description = "Liquidation status retrieved", body = LiquidationStatusResponse),
385 (status = 400, description = "Invalid wallet address"),
386 (status = 500, description = "Internal server error")
387 ),
388 tag = "liquidation"
389)]
390pub async fn get_liquidation_status(
391 State(state): State<AppState>,
392 Query(query): Query<LiquidationStatusQuery>,
393) -> Result<SonicJson<LiquidationStatusResponse>, (StatusCode, SonicJson<LiquidationStatusResponse>)>
394{
395 let wallet: WalletAddress = match query.wallet.parse() {
397 Ok(w) => w,
398 Err(_) => {
399 return Err((
400 StatusCode::BAD_REQUEST,
401 SonicJson(LiquidationStatusResponse {
402 success: false,
403 data: None,
404 error: Some("Invalid wallet address".to_string()),
405 }),
406 ));
407 }
408 };
409
410 let liquidation_reader: &dyn LiquidationReader = state.db.as_ref();
412 match liquidation_reader.get_liquidation_state(&wallet).await {
413 Ok(Some(record)) => match build_liquidation_status_data(record) {
414 Ok(data) => Ok(SonicJson(LiquidationStatusResponse {
415 success: true,
416 data: Some(data),
417 error: None,
418 })),
419 Err(e) => {
420 tracing::error!("Corrupt liquidation status for {}: {}", wallet, e);
421 Err((
422 StatusCode::INTERNAL_SERVER_ERROR,
423 SonicJson(LiquidationStatusResponse {
424 success: false,
425 data: None,
426 error: Some(format!("Failed to decode liquidation status: {}", e)),
427 }),
428 ))
429 }
430 },
431 Ok(None) => Ok(SonicJson(LiquidationStatusResponse {
432 success: true,
433 data: None,
434 error: None,
435 })),
436 Err(e) => {
437 tracing::error!("Failed to get liquidation status: {}", e);
438 Err((
439 StatusCode::INTERNAL_SERVER_ERROR,
440 SonicJson(LiquidationStatusResponse {
441 success: false,
442 data: None,
443 error: Some(format!("Failed to get liquidation status: {}", e)),
444 }),
445 ))
446 }
447 }
448}
449
450#[utoipa::path(
452 get,
453 path = "/liquidation/history",
454 params(LiquidationHistoryQuery),
455 responses(
456 (status = 200, description = "Liquidation history retrieved", body = LiquidationHistoryResponse),
457 (status = 400, description = "Invalid wallet address"),
458 (status = 500, description = "Internal server error")
459 ),
460 tag = "liquidation"
461)]
462pub async fn get_liquidation_history(
463 State(state): State<AppState>,
464 Query(query): Query<LiquidationHistoryQuery>,
465) -> Result<
466 SonicJson<LiquidationHistoryResponse>,
467 (StatusCode, SonicJson<LiquidationHistoryResponse>),
468> {
469 let wallet: WalletAddress = match query.wallet.parse() {
471 Ok(w) => w,
472 Err(_) => {
473 return Err((
474 StatusCode::BAD_REQUEST,
475 SonicJson(LiquidationHistoryResponse {
476 success: false,
477 data: vec![],
478 pagination: Pagination {
479 limit: 0,
480 offset: 0,
481 count: 0,
482 },
483 error: Some("Invalid wallet address".to_string()),
484 }),
485 ));
486 }
487 };
488
489 let limit = query.limit.unwrap_or(20);
490 let offset = query.offset.unwrap_or(0);
491
492 if limit < 0 || offset < 0 {
493 return Err((
494 StatusCode::BAD_REQUEST,
495 SonicJson(LiquidationHistoryResponse {
496 success: false,
497 data: vec![],
498 pagination: Pagination {
499 limit: 0,
500 offset: 0,
501 count: 0,
502 },
503 error: Some("limit and offset must be non-negative".to_string()),
504 }),
505 ));
506 }
507
508 let limit = limit.min(100);
509
510 let liquidation_reader: &dyn LiquidationReader = state.db.as_ref();
512 match liquidation_reader
513 .get_liquidation_history(&wallet, limit, offset)
514 .await
515 {
516 Ok((records, count)) => {
517 let data: anyhow::Result<Vec<LiquidationHistoryEntry>> = records
518 .into_iter()
519 .map(|r| {
520 Ok(LiquidationHistoryEntry {
521 id: r.id,
522 wallet: wallet.to_string(),
523 previous_state: r.previous_state,
524 new_state: r.new_state,
525 equity: r.equity,
526 mm_required: r.mm_required,
527 maintenance_margin: r.maintenance_margin,
528 shortfall: r.shortfall,
529 liquidation_mode: r.liquidation_mode,
530 auction_id: r.auction_id,
531 request_id: r.request_id,
532 tx_hash: r.tx_hash,
533 margin_needed: r.margin_needed,
534 winner_address: r.winner_address.map(|winner| winner.to_string()),
535 bonus: r.bonus,
536 details: jsonb_to_sonic(&r.details, "liquidation history details")?,
537 timestamp: r.timestamp,
538 })
539 })
540 .collect();
541
542 let data = match data {
543 Ok(data) => data,
544 Err(e) => {
545 tracing::error!("Failed to decode liquidation history for {}: {}", wallet, e);
546 return Err((
547 StatusCode::INTERNAL_SERVER_ERROR,
548 SonicJson(LiquidationHistoryResponse {
549 success: false,
550 data: vec![],
551 pagination: Pagination {
552 limit: 0,
553 offset: 0,
554 count: 0,
555 },
556 error: Some(format!("Failed to decode liquidation history: {}", e)),
557 }),
558 ));
559 }
560 };
561
562 Ok(SonicJson(LiquidationHistoryResponse {
563 success: true,
564 data,
565 pagination: Pagination {
566 limit: limit as usize,
567 offset: offset as usize,
568 count: count as usize,
569 },
570 error: None,
571 }))
572 }
573 Err(e) => {
574 tracing::error!("Failed to get liquidation history: {}", e);
575 Err((
576 StatusCode::INTERNAL_SERVER_ERROR,
577 SonicJson(LiquidationHistoryResponse {
578 success: false,
579 data: vec![],
580 pagination: Pagination {
581 limit: 0,
582 offset: 0,
583 count: 0,
584 },
585 error: Some(format!("Failed to get liquidation history: {}", e)),
586 }),
587 ))
588 }
589 }
590}
591
592#[utoipa::path(
594 get,
595 path = "/liquidation/auction/{auction_id}",
596 params(
597 ("auction_id" = String, Path, description = "Auction ID")
598 ),
599 responses(
600 (status = 200, description = "Auction details retrieved", body = LiquidationAuctionResponse),
601 (status = 404, description = "Auction not found"),
602 (status = 500, description = "Internal server error")
603 ),
604 tag = "liquidation"
605)]
606pub async fn get_liquidation_auction(
607 State(state): State<AppState>,
608 Path(auction_id): Path<String>,
609) -> Result<
610 SonicJson<LiquidationAuctionResponse>,
611 (StatusCode, SonicJson<LiquidationAuctionResponse>),
612> {
613 let liquidation_reader: &dyn LiquidationReader = state.db.as_ref();
614 match liquidation_reader
615 .get_liquidation_auction(&auction_id)
616 .await
617 {
618 Ok(Some(record)) => {
619 let positions = match jsonb_to_sonic(&record.positions, "liquidation auction positions")
620 {
621 Ok(positions) => positions,
622 Err(e) => {
623 tracing::error!(
624 "Failed to decode liquidation auction {} positions: {}",
625 record.auction_id,
626 e
627 );
628 return Err((
629 StatusCode::INTERNAL_SERVER_ERROR,
630 SonicJson(LiquidationAuctionResponse {
631 success: false,
632 data: None,
633 error: Some(format!(
634 "Failed to decode liquidation auction payload: {}",
635 e
636 )),
637 }),
638 ));
639 }
640 };
641 Ok(SonicJson(LiquidationAuctionResponse {
642 success: true,
643 data: Some(LiquidationAuctionData {
644 auction_id: record.auction_id,
645 wallet: record.wallet_address.to_string(),
646 status: present_auction_status(&record.status).to_string(),
647 positions,
648 equity_at_start: record.equity_at_start,
649 mm_shortfall_at_start: record.mm_shortfall_at_start,
650 target_equity: record.target_equity,
651 request_id: record.request_id,
652 tx_hash: record.tx_hash,
653 started_at: record.started_at,
654 chain_start_time: record.chain_start_time,
655 margin_needed: record.margin_needed,
656 stop_request_id: record.stop_request_id,
657 stop_tx_hash: record.stop_tx_hash,
658 completed_at: record.completed_at,
659 liquidator_address: record.liquidator_address.map(|a| a.to_string()),
660 bonus: record.bonus,
661 settlement_value: record.settlement_value,
662 last_observed_block: record.last_observed_block,
663 }),
664 error: None,
665 }))
666 }
667 Ok(None) => Err((
668 StatusCode::NOT_FOUND,
669 SonicJson(LiquidationAuctionResponse {
670 success: false,
671 data: None,
672 error: Some("Auction not found".to_string()),
673 }),
674 )),
675 Err(e) => {
676 tracing::error!("Failed to get auction: {}", e);
677 Err((
678 StatusCode::INTERNAL_SERVER_ERROR,
679 SonicJson(LiquidationAuctionResponse {
680 success: false,
681 data: None,
682 error: Some(format!("Failed to get auction: {}", e)),
683 }),
684 ))
685 }
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use chrono::Utc;
693 use hypercall_db::LiquidationStateRecord;
694 use hypercall_types::liquidation_state::state_str;
695 use hypercall_types::MarginMode;
696 use hypercall_types::WalletAddress;
697 use rust_decimal_macros::dec;
698 use serde_json::Value as JsonValue;
699 use sonic_rs::JsonValueTrait;
700 use std::str::FromStr;
701
702 fn test_wallet() -> WalletAddress {
703 WalletAddress::from_str("0x1111111111111111111111111111111111111111").unwrap()
704 }
705
706 fn base_pre_liquidation_record() -> LiquidationStateRecord {
707 let now = Utc::now().naive_utc();
708 LiquidationStateRecord {
709 wallet_address: test_wallet(),
710 state: state_str::PRE_LIQUIDATION.to_string(),
711 margin_mode: MarginMode::Standard.as_str().to_string(),
712 equity: dec!(700),
713 mm_required: dec!(1000),
714 maintenance_margin: dec!(-300),
715 liquidation_mode: Some("partial".to_string()),
716 target_equity: Some(dec!(1200)),
717 entered_pre_liq_at: Some(10),
718 mm_shortfall: Some(dec!(300)),
719 escalation_deadline: Some(20),
720 last_reprice_at: Some(15),
721 partial_order_request_ids: Some(JsonValue::Array(vec![JsonValue::String(
722 "req-1".to_string(),
723 )])),
724 partial_order_client_ids: Some(JsonValue::Array(vec![JsonValue::String(
725 "liq-1".to_string(),
726 )])),
727 partial_bonus_bps: Some(25),
728 auction_id: None,
729 request_id: None,
730 tx_hash: None,
731 auction_started_at: None,
732 chain_start_time: None,
733 margin_needed: None,
734 stop_request_id: None,
735 stop_tx_hash: None,
736 liquidated_at: None,
737 resolved_winner: None,
738 resolved_bonus: None,
739 resolution_tx_hash: None,
740 last_observed_block: None,
741 updated_at_ms: Some(15),
742 created_at: Some(now),
743 updated_at: Some(now),
744 }
745 }
746
747 #[test]
748 fn test_build_liquidation_status_data_derives_missing_pre_liq_shortfall() {
749 let mut record = base_pre_liquidation_record();
750 record.mm_shortfall = None;
751
752 let status = build_liquidation_status_data(record).expect("status should decode");
753 let partial = status
754 .partial_liquidation
755 .expect("pre-liquidation record should include partial metadata");
756
757 assert_eq!(partial.mm_shortfall, dec!(300));
758 }
759
760 #[test]
761 fn test_liquidation_status_response_serializes_decimal_fields_as_strings() {
762 let data = build_liquidation_status_data(base_pre_liquidation_record())
763 .expect("status should decode");
764 let response = LiquidationStatusResponse {
765 success: true,
766 data: Some(data),
767 error: None,
768 };
769
770 let encoded = sonic_rs::to_string(&response).expect("response should serialize");
771 let decoded: sonic_rs::Value =
772 sonic_rs::from_str(&encoded).expect("response should be valid JSON");
773
774 assert_eq!(decoded["data"]["equity"].as_str(), Some("700"));
775 assert_eq!(
776 decoded["data"]["partial_liquidation"]["mm_shortfall"].as_str(),
777 Some("300")
778 );
779 }
780}