Skip to main content

hypercall_api/directives/
engine_check.rs

1use crate::directives::models::{EncodedTif, HlLimitOrderAction};
2use crate::error::ApiError;
3use crate::trading_halt::TradingHaltState;
4use catalog_manager::CollateralRegistry;
5use hypercall_runtime_api::increment_pending_requests;
6use hypercall_runtime_api::UnifiedEngineRequest;
7use hypercall_types::utils::get_timestamp_millis;
8use hypercall_types::{OrderAction, OrderActionMessage, OrderInfo, OrderUpdateStatus, TimeInForce};
9use hypercall_types::{Side, WalletAddress};
10use rust_decimal::Decimal;
11use std::sync::Arc;
12use std::time::Instant;
13use tokio::sync::mpsc;
14use tokio::sync::RwLock;
15use tokio::time::{timeout, Duration};
16use uuid::Uuid;
17
18const ENGINE_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
19
20#[derive(Debug, Clone)]
21pub enum EngineCheckResult {
22    Accepted,
23    Rejected(String),
24}
25
26fn trading_halt_message(reason: &str) -> String {
27    format!(
28        "{}. Order placement is disabled while trading is halted. Existing orders can still be canceled.",
29        reason
30    )
31}
32
33fn unsupported_non_reduce_only_message(asset_id: u32) -> String {
34    format!(
35        "Unsupported asset id {asset_id} for non-reduce-only hl_limit_order; only configured collateral perp assets are supported"
36    )
37}
38
39pub async fn check_hl_limit_order(
40    order_sender: &mpsc::Sender<UnifiedEngineRequest>,
41    trading_halt: &Arc<RwLock<TradingHaltState>>,
42    collateral_registry: &CollateralRegistry,
43    account: WalletAddress,
44    recovered_signer: WalletAddress,
45    nonce: u64,
46    action: &HlLimitOrderAction,
47) -> Result<EngineCheckResult, ApiError> {
48    let asset_id = action.asset.into_inner();
49
50    if let Some(reason) = {
51        let halt_state = trading_halt.read().await;
52        halt_state
53            .global_halt
54            .as_ref()
55            .filter(|activation| activation.halted)
56            .map(|activation| {
57                trading_halt_message(&format!(
58                    "Trading is halted globally: {}",
59                    activation.reason
60                ))
61            })
62    } {
63        return Ok(EngineCheckResult::Rejected(reason));
64    }
65
66    let Some(underlying) = collateral_registry.resolve_perp_collateral_underlying(asset_id) else {
67        if action.reduce_only {
68            // Unsupported-collateral reduce-only orders cannot worsen account health because they
69            // never decrease the amount of supported collateral tracked by the engine.
70            return Ok(EngineCheckResult::Accepted);
71        }
72
73        return Ok(EngineCheckResult::Rejected(
74            unsupported_non_reduce_only_message(asset_id),
75        ));
76    };
77
78    let symbol = format!("{}-PERP", underlying);
79
80    if let Some(reason) = trading_halt.read().await.blocked_reason(&symbol) {
81        return Ok(EngineCheckResult::Rejected(trading_halt_message(&reason)));
82    }
83
84    let tif = match action.encoded_tif_kind()? {
85        EncodedTif::Alo | EncodedTif::Gtc => TimeInForce::GTC,
86        EncodedTif::Ioc => TimeInForce::IOC,
87    };
88
89    // Engine check converts raw 1e8-encoded integers into Decimal policy units.
90    let price_decimal = Decimal::from(action.limit_px.into_inner()) / Decimal::from(100_000_000u64);
91    let size_contract = Decimal::from(action.sz.into_inner()) / Decimal::from(100u64);
92
93    let cloid = action.cloid.into_inner();
94    let client_id = if cloid == 0 {
95        None
96    } else {
97        Some(format!("0x{cloid:032x}"))
98    };
99
100    let order_info = OrderInfo {
101        symbol: symbol.clone(),
102        price: price_decimal,
103        size: size_contract,
104        side: if action.is_buy { Side::Buy } else { Side::Sell },
105        tif,
106        client_id,
107        order_id: None,
108        is_perp: true,
109        underlying: Some(underlying),
110        reduce_only: Some(action.reduce_only),
111        nonce: Some(nonce),
112        signature: None,
113        mmp_enabled: false,
114        builder_code_address: None,
115    };
116
117    let order_action_msg = OrderActionMessage {
118        timestamp: get_timestamp_millis(),
119        info: order_info,
120        action: OrderAction::CreateOrder,
121        wallet: account,
122        api_wallet_address: Some(recovered_signer),
123        mmp_triggered: false,
124        request_id: Some(Uuid::now_v7().to_string()),
125    };
126
127    let (response_tx, mut response_rx) = mpsc::channel(1);
128    let engine_request = UnifiedEngineRequest {
129        message: order_action_msg,
130        response_tx,
131        enqueued_at: Instant::now(),
132        #[cfg(feature = "otel-tracing")]
133        trace_context: None,
134    };
135
136    increment_pending_requests();
137    order_sender.send(engine_request).await.map_err(|_| {
138        ApiError::internal_error("Failed to send limit order to engine validation channel")
139    })?;
140
141    let response = match timeout(ENGINE_RESPONSE_TIMEOUT, response_rx.recv()).await {
142        Ok(Some(resp)) => resp,
143        Ok(None) => {
144            return Err(ApiError::internal_error(
145                "No response from engine validation channel",
146            ))
147        }
148        Err(_) => {
149            return Err(ApiError::gateway_timeout(
150                "Timeout waiting for engine validation response",
151            ))
152        }
153    };
154
155    if response.status == OrderUpdateStatus::Rejected {
156        Ok(EngineCheckResult::Rejected(response.reason.unwrap_or_else(
157            || "Order rejected by engine policy".to_string(),
158        )))
159    } else {
160        Ok(EngineCheckResult::Accepted)
161    }
162}