Skip to main content

hypercall_api/handlers/
liquidation.rs

1//! Liquidation REST API handlers.
2//!
3//! Provides endpoints for querying liquidation status, history, and auctions.
4
5use 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/// Query parameters for liquidation status endpoint
20#[derive(Debug, Deserialize, IntoParams)]
21pub struct LiquidationStatusQuery {
22    /// Wallet address to query
23    #[param(example = "0x1234567890123456789012345678901234567890")]
24    pub wallet: String,
25}
26
27/// Query parameters for liquidation history endpoint
28#[derive(Debug, Deserialize, IntoParams)]
29pub struct LiquidationHistoryQuery {
30    /// Wallet address to query
31    #[param(example = "0x1234567890123456789012345678901234567890")]
32    pub wallet: String,
33    /// Maximum number of results to return (default: 20, max: 100)
34    #[param(example = 20)]
35    pub limit: Option<i64>,
36    /// Number of results to skip for pagination
37    #[param(example = 0)]
38    pub offset: Option<i64>,
39}
40
41/// Liquidation status response
42#[derive(Debug, Serialize, ToSchema)]
43pub struct LiquidationStatusResponse {
44    /// Whether the request was successful
45    pub success: bool,
46    /// Liquidation status data (if found)
47    pub data: Option<LiquidationStatusData>,
48    /// Error message (if any)
49    pub error: Option<String>,
50}
51
52/// Liquidation status data
53#[derive(Debug, Serialize, ToSchema)]
54pub struct LiquidationStatusData {
55    /// Wallet address
56    pub wallet: String,
57    /// Current liquidation state
58    pub state: String,
59    /// Current liquidation mode (`partial` or `full`) when active.
60    pub liquidation_mode: Option<String>,
61    /// Margin mode (standard or portfolio)
62    pub margin_mode: String,
63    /// Current equity
64    #[schema(value_type = String)]
65    #[serde(serialize_with = "serialize_decimal_string")]
66    pub equity: Decimal,
67    /// Maintenance margin required
68    #[schema(value_type = String)]
69    #[serde(serialize_with = "serialize_decimal_string")]
70    pub mm_required: Decimal,
71    /// Maintenance margin (equity - mm_required)
72    #[schema(value_type = String)]
73    #[serde(serialize_with = "serialize_decimal_string")]
74    pub maintenance_margin: Decimal,
75    /// Positive maintenance shortfall for underwater accounts.
76    #[schema(value_type = String)]
77    #[serde(serialize_with = "serialize_decimal_string")]
78    pub shortfall: Decimal,
79    /// Partial liquidation metadata, when partial liquidation is active.
80    pub partial_liquidation: Option<PartialLiquidationStatusData>,
81    /// Full liquidation metadata, when a full liquidation auction is active or resolved.
82    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/// Liquidation history response
128#[derive(Debug, Serialize, ToSchema)]
129pub struct LiquidationHistoryResponse {
130    /// Whether the request was successful
131    pub success: bool,
132    /// History entries
133    pub data: Vec<LiquidationHistoryEntry>,
134    /// Pagination info
135    pub pagination: Pagination,
136    /// Error message (if any)
137    pub error: Option<String>,
138}
139
140/// Liquidation history entry
141#[derive(Debug, Serialize, ToSchema)]
142pub struct LiquidationHistoryEntry {
143    /// History entry ID
144    pub id: i64,
145    /// Wallet address
146    pub wallet: String,
147    /// Previous liquidation state
148    pub previous_state: String,
149    /// New liquidation state
150    pub new_state: String,
151    /// Equity at time of transition
152    #[schema(value_type = String)]
153    #[serde(serialize_with = "serialize_decimal_string")]
154    pub equity: Decimal,
155    /// MM required at time of transition
156    #[schema(value_type = String)]
157    #[serde(serialize_with = "serialize_decimal_string")]
158    pub mm_required: Decimal,
159    /// Maintenance margin at time of transition
160    #[schema(value_type = String)]
161    #[serde(serialize_with = "serialize_decimal_string")]
162    pub maintenance_margin: Decimal,
163    /// Shortfall at time of transition
164    #[schema(value_type = String)]
165    #[serde(serialize_with = "serialize_decimal_string")]
166    pub shortfall: Decimal,
167    /// Liquidation mode (`partial` or `full`) if applicable
168    pub liquidation_mode: Option<String>,
169    /// Auction ID (if applicable)
170    pub auction_id: Option<String>,
171    /// Request ID associated with the transition
172    pub request_id: Option<String>,
173    /// Transaction hash associated with the transition
174    pub tx_hash: Option<String>,
175    /// Margin needed for full liquidation, when applicable
176    #[schema(value_type = Option<String>)]
177    #[serde(serialize_with = "serialize_optional_decimal_string")]
178    pub margin_needed: Option<Decimal>,
179    /// Winning liquidator/manager, if resolved
180    pub winner_address: Option<String>,
181    /// Bonus credited on resolution, if any
182    #[schema(value_type = Option<String>)]
183    #[serde(serialize_with = "serialize_optional_decimal_string")]
184    pub bonus: Option<Decimal>,
185    /// Serialized status snapshot for restart-safe debugging
186    #[schema(value_type = Object)]
187    pub details: sonic_rs::Value,
188    /// Timestamp of transition
189    pub timestamp: i64,
190}
191
192/// Liquidation auction response
193#[derive(Debug, Serialize, ToSchema)]
194pub struct LiquidationAuctionResponse {
195    /// Whether the request was successful
196    pub success: bool,
197    /// Auction data (if found)
198    pub data: Option<LiquidationAuctionData>,
199    /// Error message (if any)
200    pub error: Option<String>,
201}
202
203/// Liquidation auction data
204#[derive(Debug, Serialize, ToSchema)]
205pub struct LiquidationAuctionData {
206    /// Auction ID
207    pub auction_id: String,
208    /// Wallet being liquidated
209    pub wallet: String,
210    /// Auction status
211    pub status: String,
212    /// Positions being liquidated
213    #[schema(value_type = Object)]
214    pub positions: sonic_rs::Value,
215    /// Equity at auction start
216    #[schema(value_type = String)]
217    #[serde(serialize_with = "serialize_decimal_string")]
218    pub equity_at_start: Decimal,
219    /// MM shortfall at auction start
220    #[schema(value_type = String)]
221    #[serde(serialize_with = "serialize_decimal_string")]
222    pub mm_shortfall_at_start: Decimal,
223    /// Target equity used when sizing the liquidation
224    #[schema(value_type = Option<String>)]
225    #[serde(serialize_with = "serialize_optional_decimal_string")]
226    pub target_equity: Option<Decimal>,
227    /// Request ID for the start directive
228    pub request_id: Option<String>,
229    /// Chain transaction hash for the start directive
230    pub tx_hash: Option<String>,
231    /// Timestamp when auction started
232    pub started_at: i64,
233    /// On-chain liquidation startTime
234    pub chain_start_time: Option<i64>,
235    /// Margin needed passed to the exchange
236    #[schema(value_type = Option<String>)]
237    #[serde(serialize_with = "serialize_optional_decimal_string")]
238    pub margin_needed: Option<Decimal>,
239    /// Request ID for a pending stop directive
240    pub stop_request_id: Option<String>,
241    /// Chain transaction hash for the stop directive
242    pub stop_tx_hash: Option<String>,
243    /// Timestamp when auction completed (if applicable)
244    pub completed_at: Option<i64>,
245    /// Address of liquidator (if completed)
246    pub liquidator_address: Option<String>,
247    /// Insolvent bonus, if any
248    #[schema(value_type = Option<String>)]
249    #[serde(serialize_with = "serialize_optional_decimal_string")]
250    pub bonus: Option<Decimal>,
251    /// Settlement value (if completed)
252    #[schema(value_type = Option<String>)]
253    #[serde(serialize_with = "serialize_optional_decimal_string")]
254    pub settlement_value: Option<Decimal>,
255    /// Last block reconciled by the chain observer
256    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/// GET /liquidation/status - Get current liquidation status for a wallet
379#[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    // Parse wallet address
396    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    // Query liquidation state from database
411    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/// GET /liquidation/history - Get liquidation history for a wallet
451#[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    // Parse wallet address
470    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    // Query history from database
511    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/// GET /liquidation/auction/:auction_id - Get auction details
593#[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}