hypercall_api/directives/
engine_check.rs1use 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 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 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}