1use std::str::FromStr;
2use std::time::Instant;
3
4use rust_decimal::prelude::{FromPrimitive, ToPrimitive};
5use rust_decimal::Decimal;
6use rust_decimal_macros::dec;
7
8use crate::rfq::rfq_manager::{RfqLeg, SubmitRpiAuction};
9use crate::rpi_monitor::{self, RpiMonitorRecord};
10use crate::sonic_json::SonicJson;
11use crate::{error::ApiError, middleware::SignerContext, models::ApiResponse};
12use axum::extract::State;
13use hypercall_runtime_api::increment_pending_requests;
14use hypercall_runtime_api::BookSnapshotState;
15use hypercall_runtime_api::RfqExecuteResult;
16use hypercall_types::utils::get_timestamp_millis;
17use hypercall_types::{
18 to_contract_units_decimal, validate_price_precision, CancelOrderByCloidRequest,
19 CancelOrderRequest, ParsedOptionSymbol, PlaceOrderRequest, MAX_PRICE_SIGNIFICANT_FIGURES,
20};
21use hypercall_types::{
22 OrderAction, OrderActionMessage, OrderInfo, OrderUpdateMessage, OrderUpdateStatus, TimeInForce,
23};
24use hypercall_types::{Side, TradingModes};
25use tokio::sync::mpsc;
26use tokio::time::timeout;
27use tracing::Instrument;
28use uuid::Uuid;
29
30#[cfg(feature = "otel-tracing")]
31use tracing_opentelemetry::OpenTelemetrySpanExt;
32
33use super::{ensure_order_creation_allowed, AppState, ENGINE_RESPONSE_TIMEOUT};
34
35#[utoipa::path(
37 post,
38 path = "/order",
39 request_body = PlaceOrderRequest,
40 responses(
41 (status = 200, description = "Order placed", body = OrderUpdateMessage),
42 (status = 400, description = "Invalid order parameters"),
43 (status = 401, description = "Unauthorized"),
44 (status = 500, description = "Internal server error")
45 ),
46 security(("eip712_signature" = [])),
47 tag = "Trading"
48)]
49pub async fn place_order(
50 State(state): State<AppState>,
51 signer_ctx: SignerContext,
52 SonicJson(request): SonicJson<PlaceOrderRequest>,
53) -> Result<SonicJson<OrderUpdateMessage>, ApiError> {
54 let span = tracing::info_span!(
55 "api.place_order",
56 wallet = %signer_ctx.wallet_address,
57 symbol = %request.symbol,
58 side = ?request.side,
59 size = %request.size,
60 price = %request.price,
61 client_id = ?request.client_id,
62 action = "CreateOrder",
63 is_perp = false,
64 );
65
66 async move {
67 let price: Decimal = Decimal::from_str(&request.price).map_err(|_| {
69 tracing::warn!("Invalid price format: {}", request.price);
70 ApiError::bad_request(format!("Invalid price format: {}", request.price))
71 })?;
72 let size: Decimal = Decimal::from_str(&request.size).map_err(|_| {
73 tracing::warn!("Invalid size format: {}", request.size);
74 ApiError::bad_request(format!("Invalid size format: {}", request.size))
75 })?;
76
77 let _parsed_symbol = ParsedOptionSymbol::from_symbol(&request.symbol).map_err(|e| {
79 tracing::warn!("Order validation failed: {}", e);
80 ApiError::bad_request(format!("Invalid symbol: {}", e))
81 })?;
82
83 ensure_order_creation_allowed(&state, &request.symbol).await?;
84
85 if price <= dec!(0) {
87 return Err(ApiError::bad_request("Price must be positive"));
88 }
89 if size <= dec!(0) {
90 return Err(ApiError::bad_request("Size must be positive"));
91 }
92
93 let price_f64 = price.to_f64().ok_or_else(|| {
95 ApiError::bad_request(format!("Price {} cannot be represented as f64", price))
96 })?;
97 if let Err(e) = validate_price_precision(price_f64, MAX_PRICE_SIGNIFICANT_FIGURES) {
98 tracing::warn!("Price validation failed: {}", e);
99 return Err(ApiError::bad_request(format!(
100 "Invalid price precision: {}",
101 e
102 )));
103 }
104
105 tracing::info!(
106 "Placing order for wallet: {} (signed by: {}), symbol: {}",
107 signer_ctx.wallet_address,
108 signer_ctx.signer_address,
109 request.symbol
110 );
111
112 let order_info = OrderInfo {
114 symbol: request.symbol.clone(),
115 price,
116 size: to_contract_units_decimal(&request.symbol, size),
117 side: request.side,
118 tif: request.tif,
119 client_id: request.client_id.clone(),
120 order_id: None,
121 is_perp: false,
122 underlying: None,
123 reduce_only: None,
124 nonce: Some(request.nonce),
125 signature: None,
126 mmp_enabled: request.mmp_enabled,
127 builder_code_address: request.builder_code_address,
128 };
129
130 let order_action_msg = OrderActionMessage {
132 timestamp: get_timestamp_millis(),
133 info: order_info.clone(),
134 action: OrderAction::CreateOrder,
135 wallet: request.wallet,
136 api_wallet_address: Some(signer_ctx.signer_address),
137 mmp_triggered: false,
138 request_id: Some(Uuid::now_v7().to_string()),
139 };
140
141 if let Some(response) = maybe_execute_rpi_place_order(
142 &state,
143 &request,
144 &order_action_msg,
145 &order_info,
146 price,
147 size,
148 )
149 .await?
150 {
151 return Ok(SonicJson(response));
152 }
153
154 let response = dispatch_order_to_engine(&state.order_sender, order_action_msg).await?;
155
156 if response.status == OrderUpdateStatus::Open {
158 tracing::info!("Order placed successfully: {:?}", response.order_id);
160 }
161
162 Ok(SonicJson(response))
163 }
164 .instrument(span)
165 .await
166}
167
168async fn dispatch_order_to_engine(
169 order_sender: &mpsc::Sender<hypercall_runtime_api::UnifiedEngineRequest>,
170 order_action_msg: OrderActionMessage,
171) -> Result<OrderUpdateMessage, ApiError> {
172 let (response_tx, mut response_rx) = mpsc::channel(1);
173 let engine_request = hypercall_runtime_api::UnifiedEngineRequest {
174 message: order_action_msg,
175 response_tx,
176 enqueued_at: Instant::now(),
177 #[cfg(feature = "otel-tracing")]
178 trace_context: Some(tracing::Span::current().context()),
179 };
180
181 increment_pending_requests();
182 order_sender.send(engine_request).await.map_err(|_| {
183 tracing::error!("Failed to send order to engine");
184 ApiError::internal_error("Failed to send order to engine")
185 })?;
186
187 match timeout(ENGINE_RESPONSE_TIMEOUT, response_rx.recv()).await {
188 Ok(Some(resp)) => Ok(resp),
189 Ok(None) => {
190 tracing::error!("No response from engine");
191 Err(ApiError::internal_error("No response from engine"))
192 }
193 Err(_) => {
194 tracing::error!("Timeout waiting for engine response");
195 Err(ApiError::gateway_timeout(
196 "Timeout waiting for engine response",
197 ))
198 }
199 }
200}
201
202async fn maybe_execute_rpi_place_order(
207 state: &AppState,
208 request: &PlaceOrderRequest,
209 order_action_msg: &OrderActionMessage,
210 order_info: &OrderInfo,
211 limit_price: Decimal,
212 human_size: Decimal,
213) -> Result<Option<OrderUpdateMessage>, ApiError> {
214 let Some(rfq_manager) = state.rfq_manager.as_ref() else {
215 record_rpi_event(
216 "unavailable_manager",
217 "not_applicable",
218 Some("RFQ manager unavailable".to_string()),
219 request,
220 order_action_msg,
221 human_size,
222 limit_price,
223 None,
224 None,
225 None,
226 );
227 return Ok(None);
228 };
229
230 let Some(instrument) = state.instruments_cache.get_by_symbol(&request.symbol).await else {
231 record_rpi_event(
232 "unknown_instrument",
233 "not_applicable",
234 Some("Instrument not found".to_string()),
235 request,
236 order_action_msg,
237 human_size,
238 limit_price,
239 None,
240 None,
241 None,
242 );
243 return Ok(None);
244 };
245 if !allows_rpi_orderbook_routing(instrument.trading_mode) {
246 record_rpi_event(
247 "unsupported_trading_mode",
248 "not_applicable",
249 Some(format!(
250 "Trading mode {:?} does not allow combined RFQ/orderbook routing",
251 instrument.trading_mode
252 )),
253 request,
254 order_action_msg,
255 human_size,
256 limit_price,
257 None,
258 None,
259 None,
260 );
261 return Ok(None);
262 }
263
264 let book_reference =
265 match best_within_limit_book_price(state, &request.symbol, request.side, limit_price)? {
266 BookReferencePrice::Ready(reference) => reference,
267 BookReferencePrice::SnapshotMissing => {
268 tracing::warn!(
269 symbol = %request.symbol,
270 "Skipping RPI auction because orderbook snapshot is missing"
271 );
272 record_rpi_event(
273 "snapshot_not_ready",
274 "snapshot_not_ready",
275 Some("Orderbook snapshot is not ready".to_string()),
276 request,
277 order_action_msg,
278 human_size,
279 limit_price,
280 None,
281 None,
282 None,
283 );
284 return Ok(None);
285 }
286 };
287 let min_tick = rfq_manager.min_improvement_tick();
288 let reference_price = book_reference.reference_price;
289 let reference_state = book_reference.reference_state();
290
291 let rfq_id = Uuid::now_v7();
292 let rfq_leg_request = hypercall_types::RfqLegRequest {
293 instrument: request.symbol.clone(),
294 side: request.side,
295 size: human_size.to_string(),
296 };
297 let legs_hash = super::rfq::compute_legs_hash_from_legs(std::slice::from_ref(&rfq_leg_request))
298 .map_err(ApiError::bad_request)?;
299
300 let record = rfq_manager
301 .submit_rpi_auction(SubmitRpiAuction {
302 rfq_id,
303 taker_wallet: request.wallet,
304 taker_signer: order_action_msg
305 .api_wallet_address
306 .unwrap_or(request.wallet),
307 builder_code_address: request.builder_code_address,
308 legs: vec![RfqLeg {
309 instrument: request.symbol.clone(),
310 side: request.side,
311 size: human_size,
312 }],
313 legs_hash,
314 taker_signature: request.signature.clone(),
315 taker_nonce: request.nonce,
316 limit_price,
317 reference_price,
318 min_tick,
319 })
320 .await
321 .map_err(ApiError::bad_request)?;
322
323 record_rpi_event(
324 "auction_started",
325 reference_state,
326 None,
327 request,
328 order_action_msg,
329 human_size,
330 limit_price,
331 Some(record.rfq_id),
332 reference_price,
333 Some(book_reference.l2_seq),
334 );
335
336 let closed = rfq_manager
337 .wait_for_rpi_auction_close(record.rfq_id)
338 .await
339 .map_err(ApiError::internal_error)?;
340
341 let candidate_quote_ids = rfq_manager
342 .rpi_candidate_quote_ids(&closed.rfq_id)
343 .map_err(ApiError::internal_error)?;
344
345 for quote_id in candidate_quote_ids {
346 let execution_result = rfq_manager
347 .execute_rpi_quote(closed.rfq_id, quote_id, request.signature.clone())
348 .await;
349
350 match execution_result {
351 Ok(RfqExecuteResult::Success { fill_id }) => {
352 let rpi = closed.rpi_auction.as_ref().ok_or_else(|| {
353 ApiError::internal_error("RPI context missing after auction close")
354 })?;
355 let filled_record = rfq_manager
356 .get_rfq(&closed.rfq_id)
357 .ok_or_else(|| ApiError::internal_error("RPI RFQ missing after execution"))?;
358 let accepted_quote = filled_record
359 .quotes
360 .iter()
361 .find(|quote| quote.quote_id == quote_id)
362 .ok_or_else(|| ApiError::internal_error("RPI winning quote missing"))?;
363 let fill_price = accepted_quote
364 .legs
365 .first()
366 .map(|leg| leg.price)
367 .ok_or_else(|| ApiError::internal_error("RPI winning quote missing leg"))?;
368 if let Some(reference_price) = rpi.reference_price {
369 let improvement = match request.side {
370 Side::Buy => reference_price - fill_price,
371 Side::Sell => fill_price - reference_price,
372 };
373 if improvement > Decimal::ZERO {
374 let improvement_bps = ((improvement / reference_price) * dec!(10000))
375 .to_f64()
376 .ok_or_else(|| {
377 ApiError::internal_error(format!(
378 "RPI improvement {} cannot be represented as f64",
379 improvement
380 ))
381 })?;
382 metrics::histogram!(
383 "ht_rpi_price_improvement_bps",
384 "reference" => "book"
385 )
386 .record(improvement_bps);
387 }
388 }
389
390 let mut filled_info = order_info.clone();
391 filled_info.price = fill_price;
392 tracing::info!(
393 rfq_id = %closed.rfq_id,
394 quote_id = %quote_id,
395 fill_id = %fill_id,
396 symbol = %request.symbol,
397 limit_price = %limit_price,
398 fill_price = %fill_price,
399 "RPI auction filled PlaceOrder"
400 );
401 record_rpi_fill_event(
402 "filled",
403 reference_state,
404 None,
405 request,
406 order_action_msg,
407 human_size,
408 limit_price,
409 closed.rfq_id,
410 quote_id,
411 Some(fill_id.clone()),
412 reference_price,
413 Some(fill_price),
414 Some(book_reference.l2_seq),
415 );
416 return Ok(Some(OrderUpdateMessage {
417 timestamp: get_timestamp_millis(),
418 info: filled_info,
419 status: OrderUpdateStatus::Filled,
420 reason: Some("Filled through RPI auction".to_string()),
421 filled_size: order_info.size,
422 order_id: None,
423 wallet_address: request.wallet,
424 mmp_triggered: false,
425 request_id: order_action_msg.request_id.clone(),
426 }));
427 }
428 Ok(RfqExecuteResult::Failed { reason }) => {
429 tracing::warn!(
430 rfq_id = %closed.rfq_id,
431 quote_id = %quote_id,
432 reason = %reason,
433 "RPI quote execution failed, trying next candidate"
434 );
435 record_rpi_fill_event(
436 "quote_failed",
437 reference_state,
438 Some(reason),
439 request,
440 order_action_msg,
441 human_size,
442 limit_price,
443 closed.rfq_id,
444 quote_id,
445 None,
446 reference_price,
447 None,
448 Some(book_reference.l2_seq),
449 );
450 }
451 Err(reason) => {
452 tracing::warn!(
453 rfq_id = %closed.rfq_id,
454 quote_id = %quote_id,
455 reason = %reason,
456 "RPI quote execution errored, trying next candidate"
457 );
458 record_rpi_fill_event(
459 "quote_error",
460 reference_state,
461 Some(reason),
462 request,
463 order_action_msg,
464 human_size,
465 limit_price,
466 closed.rfq_id,
467 quote_id,
468 None,
469 reference_price,
470 None,
471 Some(book_reference.l2_seq),
472 );
473 }
474 }
475 }
476
477 rfq_manager.mark_rpi_fallback_to_book(&closed.rfq_id);
478 let response = dispatch_order_to_engine(&state.order_sender, order_action_msg.clone()).await?;
479 record_rpi_event_with_order(
480 "fallback_to_book",
481 reference_state,
482 response.reason.clone(),
483 request,
484 order_action_msg,
485 human_size,
486 limit_price,
487 Some(closed.rfq_id),
488 reference_price,
489 Some(book_reference.l2_seq),
490 response.order_id,
491 );
492 Ok(Some(response))
493}
494
495fn allows_rpi_orderbook_routing(trading_mode: TradingModes) -> bool {
496 trading_mode.allows_rfq() && trading_mode.allows_orderbook()
497}
498
499#[derive(Debug, Clone, PartialEq, Eq)]
500enum BookReferencePrice {
501 SnapshotMissing,
502 Ready(BookReference),
503}
504
505#[derive(Debug, Clone, PartialEq, Eq)]
506struct BookReference {
507 reference_price: Option<Decimal>,
508 empty_book: bool,
509 l2_seq: i64,
510}
511
512impl BookReference {
513 fn reference_state(&self) -> &'static str {
514 match (self.reference_price.is_some(), self.empty_book) {
515 (true, _) => "book",
516 (false, true) => "none_empty_book",
517 (false, false) => "none_no_executable_liquidity",
518 }
519 }
520}
521
522fn best_within_limit_book_price(
523 state: &AppState,
524 symbol: &str,
525 side: Side,
526 limit_price: Decimal,
527) -> Result<BookReferencePrice, ApiError> {
528 book_reference_from_snapshot_state(
529 state.quote_provider.book_snapshot_state(symbol),
530 side,
531 limit_price,
532 )
533}
534
535fn book_reference_from_snapshot_state(
536 snapshot_state: BookSnapshotState,
537 side: Side,
538 limit_price: Decimal,
539) -> Result<BookReferencePrice, ApiError> {
540 let (quote, l2_seq) = match snapshot_state {
541 BookSnapshotState::NotReady { .. } => return Ok(BookReferencePrice::SnapshotMissing),
542 BookSnapshotState::Ready { quote, l2_seq } => (quote, l2_seq),
543 };
544
545 let levels = match side {
546 Side::Buy => "e.asks,
547 Side::Sell => "e.bids,
548 };
549
550 best_within_limit_level_price(levels, side, limit_price).map(|reference_price| {
551 BookReferencePrice::Ready(BookReference {
552 reference_price,
553 empty_book: quote.is_empty_book(),
554 l2_seq,
555 })
556 })
557}
558
559fn record_rpi_event(
560 outcome: &'static str,
561 reference_state: &'static str,
562 reason: Option<String>,
563 request: &PlaceOrderRequest,
564 order_action_msg: &OrderActionMessage,
565 human_size: Decimal,
566 limit_price: Decimal,
567 rfq_id: Option<Uuid>,
568 reference_price: Option<Decimal>,
569 l2_seq: Option<i64>,
570) {
571 record_rpi_event_with_order(
572 outcome,
573 reference_state,
574 reason,
575 request,
576 order_action_msg,
577 human_size,
578 limit_price,
579 rfq_id,
580 reference_price,
581 l2_seq,
582 None,
583 );
584}
585
586fn record_rpi_event_with_order(
587 outcome: &'static str,
588 reference_state: &'static str,
589 reason: Option<String>,
590 request: &PlaceOrderRequest,
591 order_action_msg: &OrderActionMessage,
592 human_size: Decimal,
593 limit_price: Decimal,
594 rfq_id: Option<Uuid>,
595 reference_price: Option<Decimal>,
596 l2_seq: Option<i64>,
597 order_id: Option<u64>,
598) {
599 rpi_monitor::record(RpiMonitorRecord {
600 outcome,
601 reference_state,
602 reason,
603 wallet: request.wallet.to_string(),
604 symbol: request.symbol.clone(),
605 side: format!("{:?}", request.side),
606 size: human_size.to_string(),
607 limit_price: limit_price.to_string(),
608 reference_price: reference_price.map(|price| price.to_string()),
609 fill_price: None,
610 rfq_id: rfq_id.map(|id| id.to_string()),
611 quote_id: None,
612 fill_id: None,
613 order_id,
614 request_id: order_action_msg.request_id.clone(),
615 l2_seq,
616 });
617}
618
619fn record_rpi_fill_event(
620 outcome: &'static str,
621 reference_state: &'static str,
622 reason: Option<String>,
623 request: &PlaceOrderRequest,
624 order_action_msg: &OrderActionMessage,
625 human_size: Decimal,
626 limit_price: Decimal,
627 rfq_id: Uuid,
628 quote_id: Uuid,
629 fill_id: Option<String>,
630 reference_price: Option<Decimal>,
631 fill_price: Option<Decimal>,
632 l2_seq: Option<i64>,
633) {
634 rpi_monitor::record(RpiMonitorRecord {
635 outcome,
636 reference_state,
637 reason,
638 wallet: request.wallet.to_string(),
639 symbol: request.symbol.clone(),
640 side: format!("{:?}", request.side),
641 size: human_size.to_string(),
642 limit_price: limit_price.to_string(),
643 reference_price: reference_price.map(|price| price.to_string()),
644 fill_price: fill_price.map(|price| price.to_string()),
645 rfq_id: Some(rfq_id.to_string()),
646 quote_id: Some(quote_id.to_string()),
647 fill_id,
648 order_id: None,
649 request_id: order_action_msg.request_id.clone(),
650 l2_seq,
651 });
652}
653
654fn best_within_limit_level_price(
655 levels: &[(f64, f64)],
656 side: Side,
657 limit_price: Decimal,
658) -> Result<Option<Decimal>, ApiError> {
659 for (price, size) in levels {
660 if !price.is_finite() || *price <= 0.0 || !size.is_finite() || *size <= 0.0 {
661 continue;
662 }
663
664 let decimal_price = Decimal::from_f64(*price).ok_or_else(|| {
665 ApiError::internal_error(format!(
666 "Book price {} cannot be represented as Decimal",
667 price
668 ))
669 })?;
670 let crosses_limit = match side {
671 Side::Buy => decimal_price <= limit_price,
672 Side::Sell => decimal_price >= limit_price,
673 };
674 if !crosses_limit {
675 break;
676 }
677
678 return Ok(Some(decimal_price));
679 }
680
681 Ok(None)
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687 use hypercall_runtime_api::SnapshotBookQuote;
688 use rust_decimal_macros::dec;
689
690 #[test]
691 fn rpi_orderbook_routing_requires_combined_trading_mode() {
692 assert!(allows_rpi_orderbook_routing(
693 TradingModes::ORDERBOOK | TradingModes::RFQ
694 ));
695 assert!(!allows_rpi_orderbook_routing(TradingModes::ORDERBOOK));
696 assert!(!allows_rpi_orderbook_routing(TradingModes::RFQ));
697 assert!(!allows_rpi_orderbook_routing(TradingModes::empty()));
698 }
699
700 #[test]
701 fn rpi_buy_reference_uses_partial_resting_ask() {
702 let levels = vec![(99.0, 0.01)];
703
704 let reference = best_within_limit_level_price(&levels, Side::Buy, dec!(100)).unwrap();
705
706 assert_eq!(reference, Some(dec!(99)));
707 }
708
709 #[test]
710 fn rpi_buy_reference_absent_when_best_ask_misses_limit() {
711 let levels = vec![(101.0, 100.0)];
712
713 let reference = best_within_limit_level_price(&levels, Side::Buy, dec!(100)).unwrap();
714
715 assert_eq!(reference, None);
716 }
717
718 #[test]
719 fn rpi_sell_reference_uses_partial_resting_bid() {
720 let levels = vec![(101.0, 0.01)];
721
722 let reference = best_within_limit_level_price(&levels, Side::Sell, dec!(100)).unwrap();
723
724 assert_eq!(reference, Some(dec!(101)));
725 }
726
727 #[test]
728 fn rpi_reference_distinguishes_missing_snapshot_from_empty_book() {
729 let missing = book_reference_from_snapshot_state(
730 BookSnapshotState::NotReady { l2_seq: 0 },
731 Side::Buy,
732 dec!(100),
733 )
734 .unwrap();
735 assert_eq!(missing, BookReferencePrice::SnapshotMissing);
736
737 let ready_empty = book_reference_from_snapshot_state(
738 BookSnapshotState::Ready {
739 quote: SnapshotBookQuote::empty(),
740 l2_seq: 1,
741 },
742 Side::Buy,
743 dec!(100),
744 )
745 .unwrap();
746
747 assert_eq!(
748 ready_empty,
749 BookReferencePrice::Ready(BookReference {
750 reference_price: None,
751 empty_book: true,
752 l2_seq: 1,
753 })
754 );
755 }
756}
757
758#[utoipa::path(
760 delete,
761 path = "/order",
762 request_body = CancelOrderRequest,
763 responses(
764 (status = 200, description = "Order cancelled", body = OrderUpdateMessage),
765 (status = 400, description = "Invalid request"),
766 (status = 401, description = "Unauthorized"),
767 (status = 500, description = "Internal server error")
768 ),
769 security(("eip712_signature" = [])),
770 tag = "Trading"
771)]
772pub async fn cancel_order(
773 State(state): State<AppState>,
774 signer_ctx: SignerContext,
775 SonicJson(request): SonicJson<CancelOrderRequest>,
776) -> Result<SonicJson<ApiResponse<OrderUpdateMessage>>, ApiError> {
777 let span = tracing::info_span!(
778 "api.cancel_order",
779 wallet = %signer_ctx.wallet_address,
780 order_id = %request.order_id,
781 action = "CancelOrder",
782 );
783
784 async move {
785 tracing::info!(
786 "Canceling order {} for wallet: {} (signed by: {})",
787 request.order_id,
788 signer_ctx.wallet_address,
789 signer_ctx.signer_address
790 );
791
792 let order_info = OrderInfo {
794 symbol: String::new(), price: dec!(0),
796 size: dec!(0),
797 side: Side::Buy, tif: TimeInForce::GTC,
799 client_id: None,
800 order_id: Some(request.order_id),
801 is_perp: false,
802 underlying: None,
803 reduce_only: None,
804 nonce: Some(request.nonce),
805 signature: None,
806 mmp_enabled: false,
807 builder_code_address: None,
808 };
809
810 let order_action_msg = OrderActionMessage {
812 timestamp: get_timestamp_millis(),
813 info: order_info,
814 action: OrderAction::CancelOrder,
815 wallet: signer_ctx.wallet_address,
816 api_wallet_address: Some(signer_ctx.signer_address),
817 mmp_triggered: false,
818 request_id: Some(Uuid::now_v7().to_string()),
819 };
820
821 let (response_tx, mut response_rx) = mpsc::channel(1);
823
824 let engine_request = hypercall_runtime_api::UnifiedEngineRequest {
826 message: order_action_msg,
827 response_tx,
828 enqueued_at: Instant::now(),
829 #[cfg(feature = "otel-tracing")]
830 trace_context: Some(tracing::Span::current().context()),
831 };
832
833 increment_pending_requests();
835 state.order_sender.send(engine_request).await.map_err(|_| {
836 tracing::error!("Failed to send cancel request to engine");
837 ApiError::internal_error("Failed to send cancel request to engine")
838 })?;
839
840 let response = match timeout(ENGINE_RESPONSE_TIMEOUT, response_rx.recv()).await {
842 Ok(Some(resp)) => resp,
843 Ok(None) => {
844 tracing::error!("No response from engine");
845 return Err(ApiError::internal_error("No response from engine"));
846 }
847 Err(_) => {
848 tracing::error!("Timeout waiting for engine response");
849 return Err(ApiError::gateway_timeout(
850 "Timeout waiting for engine response",
851 ));
852 }
853 };
854
855 let success = response.status == OrderUpdateStatus::Canceled;
857
858 Ok(SonicJson(ApiResponse {
859 success,
860 data: Some(response),
861 error: None,
862 }))
863 }
864 .instrument(span)
865 .await
866}
867
868#[utoipa::path(
870 delete,
871 path = "/order_cloid",
872 request_body = CancelOrderByCloidRequest,
873 responses(
874 (status = 200, description = "Order cancelled", body = OrderUpdateMessage),
875 (status = 400, description = "Invalid request"),
876 (status = 401, description = "Unauthorized"),
877 (status = 500, description = "Internal server error")
878 ),
879 security(("eip712_signature" = [])),
880 tag = "Trading"
881)]
882pub async fn cancel_order_by_cloid(
883 State(state): State<AppState>,
884 signer_ctx: SignerContext,
885 SonicJson(request): SonicJson<CancelOrderByCloidRequest>,
886) -> Result<SonicJson<ApiResponse<OrderUpdateMessage>>, ApiError> {
887 tracing::info!(
888 "Canceling order by client_id {} for wallet: {} (signed by: {})",
889 request.client_id,
890 signer_ctx.wallet_address,
891 signer_ctx.signer_address
892 );
893
894 let order_info = OrderInfo {
896 symbol: String::new(), price: dec!(0),
898 size: dec!(0),
899 side: Side::Buy, tif: TimeInForce::GTC,
901 client_id: Some(request.client_id.clone()),
902 order_id: None,
903 is_perp: false,
904 underlying: None,
905 reduce_only: None,
906 nonce: Some(request.nonce),
907 signature: None,
908 mmp_enabled: false,
909 builder_code_address: None,
910 };
911
912 let order_action_msg = OrderActionMessage {
914 timestamp: get_timestamp_millis(),
915 info: order_info,
916 action: OrderAction::CancelOrder,
917 wallet: signer_ctx.wallet_address,
918 api_wallet_address: Some(signer_ctx.signer_address),
919 mmp_triggered: false,
920 request_id: Some(Uuid::now_v7().to_string()),
921 };
922
923 let (response_tx, mut response_rx) = mpsc::channel(1);
925
926 let engine_request = hypercall_runtime_api::UnifiedEngineRequest {
928 message: order_action_msg,
929 response_tx,
930 enqueued_at: Instant::now(),
931 #[cfg(feature = "otel-tracing")]
932 trace_context: Some(tracing::Span::current().context()),
933 };
934
935 increment_pending_requests();
937 state.order_sender.send(engine_request).await.map_err(|_| {
938 tracing::error!("Failed to send cancel request to engine");
939 ApiError::internal_error("Failed to send cancel request to engine")
940 })?;
941
942 let response = match timeout(ENGINE_RESPONSE_TIMEOUT, response_rx.recv()).await {
944 Ok(Some(resp)) => resp,
945 Ok(None) => {
946 tracing::error!("No response from engine");
947 return Err(ApiError::internal_error("No response from engine"));
948 }
949 Err(_) => {
950 tracing::error!("Timeout waiting for engine response");
951 return Err(ApiError::gateway_timeout(
952 "Timeout waiting for engine response",
953 ));
954 }
955 };
956
957 let success = response.status == OrderUpdateStatus::Canceled;
959
960 Ok(SonicJson(ApiResponse {
961 success,
962 data: Some(response),
963 error: None,
964 }))
965}