Skip to main content

hypercall_api/handlers/
orders.rs

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/// Place an options order
36#[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        // Parse price and size from strings as Decimal for precision
68        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        // Validate the order by parsing the symbol
78        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        // Validate order parameters
86        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        // Validate price precision (max 5 significant figures)
94        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        // Create OrderInfo
113        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        // Create OrderActionMessage
131        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        // Store order in database if it was accepted
157        if response.status == OrderUpdateStatus::Open {
158            // TODO: Store order in database
159            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
202// RPI auction discovery is intentionally outside the deterministic engine loop:
203// it depends on live QP WebSocket sessions, timers, and network responses. Any
204// winning quote is still executed through the engine as RfqExecute; no fill is
205// applied from the API handler.
206async 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 => &quote.asks,
547        Side::Sell => &quote.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/// Cancel an order by order ID
759#[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        // Create minimal OrderInfo for cancellation
793        let order_info = OrderInfo {
794            symbol: String::new(), // Will be looked up by order_id in the engine
795            price: dec!(0),
796            size: dec!(0),
797            side: Side::Buy, // Dummy value
798            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        // Create OrderActionMessage with CancelOrder action
811        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        // Create a channel for the response
822        let (response_tx, mut response_rx) = mpsc::channel(1);
823
824        // Create engine request
825        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        // Send cancel request to RSM engine
834        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        // Wait for response from engine with timeout
841        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        // Check if cancellation was successful based on the status
856        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/// Cancel an order by client order ID
869#[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    // Create minimal OrderInfo for cancellation
895    let order_info = OrderInfo {
896        symbol: String::new(), // Will be looked up by client_id in the engine
897        price: dec!(0),
898        size: dec!(0),
899        side: Side::Buy, // Dummy value
900        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    // Create OrderActionMessage with CancelOrder action
913    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    // Create a channel for the response
924    let (response_tx, mut response_rx) = mpsc::channel(1);
925
926    // Create engine request
927    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    // Send cancel request to RSM engine
936    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    // Wait for response from engine with timeout
943    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    // Check if cancellation was successful based on the status
958    let success = response.status == OrderUpdateStatus::Canceled;
959
960    Ok(SonicJson(ApiResponse {
961        success,
962        data: Some(response),
963        error: None,
964    }))
965}