1use alloy::signers::local::PrivateKeySigner;
19use alloy_primitives::{Address, B256, U256};
20use anyhow::Context;
21use nautilus_core::serialization::{
22 deserialize_decimal, serialize_decimal_as_str, serialize_optional_decimal_as_str,
23};
24use nautilus_model::orders::{Order, OrderAny};
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27use ustr::Ustr;
28
29use crate::{
30 common::{
31 consts::DERIVE_NAUTILUS_REFERRAL_CODE,
32 enums::{
33 DeriveOrderSide, DeriveOrderType, DeriveTimeInForce, DeriveTriggerPriceType,
34 DeriveTriggerType,
35 },
36 parse::{
37 order_side_to_derive, order_type_to_derive, time_in_force_to_derive,
38 trigger_order_type_to_derive, trigger_price_type_to_derive, trigger_type_to_derive,
39 },
40 },
41 http::models::DeriveInstrument,
42 signing::{
43 eip712::{ActionContext, SignedAction},
44 modules::{ModuleData, trade::TradeModuleData},
45 },
46};
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
50pub struct DeriveSignedEnvelope {
51 pub subaccount_id: u64,
53 pub nonce: u64,
55 pub signer: String,
57 pub signature_expiry_sec: i64,
59 pub signature: String,
61}
62
63impl DeriveSignedEnvelope {
64 #[must_use]
65 pub fn from_signed_action<M: ModuleData>(action: &SignedAction<'_, M>) -> Self {
66 Self {
67 subaccount_id: action.subaccount_id(),
68 nonce: action.nonce(),
69 signer: format!("{:?}", action.signer_address()),
70 signature_expiry_sec: action.signature_expiry_sec(),
71 signature: action.signature_hex(),
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
78pub struct DeriveOrderParams {
79 #[serde(flatten)]
81 pub envelope: DeriveSignedEnvelope,
82 pub instrument_name: Ustr,
84 pub direction: DeriveOrderSide,
86 pub order_type: DeriveOrderType,
88 pub time_in_force: DeriveTimeInForce,
90 #[serde(
92 serialize_with = "serialize_decimal_as_str",
93 deserialize_with = "deserialize_decimal"
94 )]
95 pub limit_price: Decimal,
96 #[serde(
98 serialize_with = "serialize_decimal_as_str",
99 deserialize_with = "deserialize_decimal"
100 )]
101 pub amount: Decimal,
102 #[serde(
104 serialize_with = "serialize_decimal_as_str",
105 deserialize_with = "deserialize_decimal"
106 )]
107 pub max_fee: Decimal,
108 pub label: String,
110 pub referral_code: String,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub reduce_only: Option<bool>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub mmp: Option<bool>,
118 #[serde(
120 default,
121 skip_serializing_if = "Option::is_none",
122 serialize_with = "serialize_optional_decimal_as_str",
123 deserialize_with = "nautilus_core::serialization::deserialize_optional_decimal"
124 )]
125 pub trigger_price: Option<Decimal>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub trigger_price_type: Option<DeriveTriggerPriceType>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub trigger_type: Option<DeriveTriggerType>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
136pub struct DeriveTriggerOrderParams {
137 #[serde(flatten)]
139 pub order: DeriveOrderParams,
140 pub conn_id: String,
142 pub order_id: String,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
148pub struct DeriveReplaceParams {
149 #[serde(flatten)]
151 pub order: DeriveOrderParams,
152 pub order_id_to_cancel: String,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
158pub struct DeriveCancelTriggerOrderParams {
159 pub subaccount_id: u64,
161 pub order_id: String,
163}
164
165impl DeriveCancelTriggerOrderParams {
166 #[must_use]
167 pub fn new(subaccount_id: u64, order_id: impl Into<String>) -> Self {
168 Self {
169 subaccount_id,
170 order_id: order_id.into(),
171 }
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
177pub struct DeriveCancelParams {
178 pub subaccount_id: u64,
180 pub instrument_name: Ustr,
182 pub order_id: String,
184}
185
186impl DeriveCancelParams {
187 #[must_use]
188 pub fn new(
189 subaccount_id: u64,
190 instrument_name: impl Into<Ustr>,
191 order_id: impl Into<String>,
192 ) -> Self {
193 Self {
194 subaccount_id,
195 instrument_name: instrument_name.into(),
196 order_id: order_id.into(),
197 }
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
203pub struct DeriveCancelByInstrumentParams {
204 pub subaccount_id: u64,
206 pub instrument_name: Ustr,
208}
209
210impl DeriveCancelByInstrumentParams {
211 #[must_use]
212 pub fn new(subaccount_id: u64, instrument_name: impl Into<Ustr>) -> Self {
213 Self {
214 subaccount_id,
215 instrument_name: instrument_name.into(),
216 }
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
222pub struct DeriveCancelAllParams {
223 pub subaccount_id: u64,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub instrument_name: Option<Ustr>,
228}
229
230impl DeriveCancelAllParams {
231 #[must_use]
232 pub const fn new(subaccount_id: u64) -> Self {
233 Self {
234 subaccount_id,
235 instrument_name: None,
236 }
237 }
238
239 #[must_use]
240 pub fn with_instrument_name(mut self, instrument_name: impl Into<Ustr>) -> Self {
241 self.instrument_name = Some(instrument_name.into());
242 self
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
248pub struct DeriveCancelByLabelParams {
249 pub subaccount_id: u64,
251 pub label: String,
253}
254
255impl DeriveCancelByLabelParams {
256 #[must_use]
257 pub fn new(subaccount_id: u64, label: impl Into<String>) -> Self {
258 Self {
259 subaccount_id,
260 label: label.into(),
261 }
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
267pub struct DeriveGetSubaccountParams {
268 pub subaccount_id: u64,
270}
271
272impl DeriveGetSubaccountParams {
273 #[must_use]
274 pub const fn new(subaccount_id: u64) -> Self {
275 Self { subaccount_id }
276 }
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
281pub struct DeriveGetOpenOrdersParams {
282 pub subaccount_id: u64,
284}
285
286impl DeriveGetOpenOrdersParams {
287 #[must_use]
288 pub const fn new(subaccount_id: u64) -> Self {
289 Self { subaccount_id }
290 }
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
295pub struct DeriveGetTriggerOrdersParams {
296 pub subaccount_id: u64,
298}
299
300impl DeriveGetTriggerOrdersParams {
301 #[must_use]
302 pub const fn new(subaccount_id: u64) -> Self {
303 Self { subaccount_id }
304 }
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
309pub struct DeriveGetOrderParams {
310 pub subaccount_id: u64,
312 pub order_id: String,
314}
315
316impl DeriveGetOrderParams {
317 #[must_use]
318 pub fn new(subaccount_id: u64, order_id: impl Into<String>) -> Self {
319 Self {
320 subaccount_id,
321 order_id: order_id.into(),
322 }
323 }
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
328pub struct DeriveGetOrderHistoryParams {
329 pub subaccount_id: u64,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub instrument_name: Option<Ustr>,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub from_timestamp: Option<i64>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub to_timestamp: Option<i64>,
340 pub page: u32,
342 pub page_size: u32,
344}
345
346impl DeriveGetOrderHistoryParams {
347 #[must_use]
348 pub fn new(subaccount_id: u64, page: u32, page_size: u32) -> Self {
349 Self {
350 subaccount_id,
351 instrument_name: None,
352 from_timestamp: None,
353 to_timestamp: None,
354 page,
355 page_size,
356 }
357 }
358
359 #[must_use]
360 pub fn with_instrument_name(mut self, instrument_name: impl Into<Ustr>) -> Self {
361 self.instrument_name = Some(instrument_name.into());
362 self
363 }
364
365 #[must_use]
366 pub const fn with_window(
367 mut self,
368 from_timestamp: Option<i64>,
369 to_timestamp: Option<i64>,
370 ) -> Self {
371 self.from_timestamp = from_timestamp;
372 self.to_timestamp = to_timestamp;
373 self
374 }
375}
376
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
379pub struct DeriveGetTradeHistoryParams {
380 pub subaccount_id: u64,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub instrument_name: Option<Ustr>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub from_timestamp: Option<i64>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub to_timestamp: Option<i64>,
391 pub page: u32,
393 pub page_size: u32,
395}
396
397impl DeriveGetTradeHistoryParams {
398 #[must_use]
399 pub fn new(subaccount_id: u64, page: u32, page_size: u32) -> Self {
400 Self {
401 subaccount_id,
402 instrument_name: None,
403 from_timestamp: None,
404 to_timestamp: None,
405 page,
406 page_size,
407 }
408 }
409
410 #[must_use]
411 pub fn with_instrument_name(mut self, instrument_name: impl Into<Ustr>) -> Self {
412 self.instrument_name = Some(instrument_name.into());
413 self
414 }
415
416 #[must_use]
417 pub const fn with_window(
418 mut self,
419 from_timestamp: Option<i64>,
420 to_timestamp: Option<i64>,
421 ) -> Self {
422 self.from_timestamp = from_timestamp;
423 self.to_timestamp = to_timestamp;
424 self
425 }
426}
427
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
430pub struct DeriveGetPositionsParams {
431 pub subaccount_id: u64,
433}
434
435impl DeriveGetPositionsParams {
436 #[must_use]
437 pub const fn new(subaccount_id: u64) -> Self {
438 Self { subaccount_id }
439 }
440}
441
442#[expect(clippy::too_many_arguments)]
458pub fn order_to_derive_payload(
459 order: &OrderAny,
460 instrument: &DeriveInstrument,
461 subaccount_id: u64,
462 wallet: Address,
463 signer: &PrivateKeySigner,
464 nonce: u64,
465 signature_expiry_sec: i64,
466 module_address: Address,
467 domain_separator: B256,
468 action_typehash: B256,
469 max_fee: Decimal,
470 explicit_price: Option<Decimal>,
471) -> anyhow::Result<DeriveOrderParams> {
472 validate_order_support(order)?;
473 let limit_price = resolve_limit_price(order, explicit_price)?;
474 let amount = order.quantity().as_decimal();
475 let order_type = order_type_to_derive(order.order_type())?;
476 let time_in_force = time_in_force_to_derive(order.time_in_force(), order.is_post_only())?;
477 build_signed_order_params(
478 order,
479 instrument,
480 subaccount_id,
481 wallet,
482 signer,
483 nonce,
484 signature_expiry_sec,
485 module_address,
486 domain_separator,
487 action_typehash,
488 max_fee,
489 limit_price,
490 amount,
491 order_type,
492 time_in_force,
493 None,
494 )
495}
496
497#[expect(clippy::too_many_arguments)]
509pub fn trigger_order_to_derive_payload(
510 order: &OrderAny,
511 instrument: &DeriveInstrument,
512 subaccount_id: u64,
513 wallet: Address,
514 signer: &PrivateKeySigner,
515 nonce: u64,
516 signature_expiry_sec: i64,
517 module_address: Address,
518 domain_separator: B256,
519 action_typehash: B256,
520 max_fee: Decimal,
521 explicit_price: Option<Decimal>,
522 conn_id: impl Into<String>,
523 order_id: impl Into<String>,
524) -> anyhow::Result<DeriveTriggerOrderParams> {
525 validate_trigger_order_support(order)?;
526 let limit_price = resolve_limit_price(order, explicit_price)?;
527 let amount = order.quantity().as_decimal();
528 let order_type = trigger_order_type_to_derive(order.order_type())?;
529 let time_in_force = time_in_force_to_derive(order.time_in_force(), order.is_post_only())?;
530 let trigger_price = order.trigger_price().ok_or_else(|| {
531 anyhow::anyhow!(
532 "missing trigger price for Derive trigger order {}",
533 order.client_order_id()
534 )
535 })?;
536 let trigger_fields = DeriveTriggerFields {
537 trigger_price: trigger_price.as_decimal(),
538 trigger_price_type: trigger_price_type_to_derive(order.trigger_type())?,
539 trigger_type: trigger_type_to_derive(order.order_type())?,
540 };
541 let order = build_signed_order_params(
542 order,
543 instrument,
544 subaccount_id,
545 wallet,
546 signer,
547 nonce,
548 signature_expiry_sec,
549 module_address,
550 domain_separator,
551 action_typehash,
552 max_fee,
553 limit_price,
554 amount,
555 order_type,
556 time_in_force,
557 Some(trigger_fields),
558 )?;
559
560 Ok(DeriveTriggerOrderParams {
561 order,
562 conn_id: conn_id.into(),
563 order_id: order_id.into(),
564 })
565}
566
567#[expect(clippy::too_many_arguments)]
580pub fn order_replace_to_derive_payload(
581 order: &OrderAny,
582 instrument: &DeriveInstrument,
583 subaccount_id: u64,
584 wallet: Address,
585 signer: &PrivateKeySigner,
586 nonce: u64,
587 signature_expiry_sec: i64,
588 module_address: Address,
589 domain_separator: B256,
590 action_typehash: B256,
591 max_fee: Decimal,
592 explicit_quantity: Option<Decimal>,
593 explicit_price: Option<Decimal>,
594 order_id_to_cancel: &str,
595) -> anyhow::Result<DeriveReplaceParams> {
596 validate_order_support(order)?;
597 let limit_price = resolve_limit_price(order, explicit_price)?;
598 let amount = explicit_quantity.unwrap_or_else(|| order.quantity().as_decimal());
599 let order_type = order_type_to_derive(order.order_type())?;
600 let time_in_force = time_in_force_to_derive(order.time_in_force(), order.is_post_only())?;
601 let order = build_signed_order_params(
602 order,
603 instrument,
604 subaccount_id,
605 wallet,
606 signer,
607 nonce,
608 signature_expiry_sec,
609 module_address,
610 domain_separator,
611 action_typehash,
612 max_fee,
613 limit_price,
614 amount,
615 order_type,
616 time_in_force,
617 None,
618 )?;
619
620 Ok(DeriveReplaceParams {
621 order,
622 order_id_to_cancel: order_id_to_cancel.to_string(),
623 })
624}
625
626pub(crate) fn validate_order_support(order: &OrderAny) -> anyhow::Result<()> {
627 order_type_to_derive(order.order_type())?;
628 time_in_force_to_derive(order.time_in_force(), order.is_post_only())?;
629 Ok(())
630}
631
632pub(crate) fn validate_trigger_order_support(order: &OrderAny) -> anyhow::Result<()> {
633 trigger_order_type_to_derive(order.order_type())?;
634 time_in_force_to_derive(order.time_in_force(), order.is_post_only())?;
635 trigger_price_type_to_derive(order.trigger_type())?;
636 Ok(())
637}
638
639fn resolve_limit_price(
640 order: &OrderAny,
641 explicit_price: Option<Decimal>,
642) -> anyhow::Result<Decimal> {
643 match explicit_price {
644 Some(p) => Ok(p),
645 None => match order.price() {
646 Some(p) => Ok(p.as_decimal()),
647 None => anyhow::bail!(
648 "missing limit price for order {} (market orders require an explicit slippage-adjusted price)",
649 order.client_order_id(),
650 ),
651 },
652 }
653}
654
655#[derive(Debug, Clone, Copy, PartialEq, Eq)]
656struct DeriveTriggerFields {
657 trigger_price: Decimal,
658 trigger_price_type: DeriveTriggerPriceType,
659 trigger_type: DeriveTriggerType,
660}
661
662#[expect(clippy::too_many_arguments)]
663fn build_signed_order_params(
664 order: &OrderAny,
665 instrument: &DeriveInstrument,
666 subaccount_id: u64,
667 wallet: Address,
668 signer: &PrivateKeySigner,
669 nonce: u64,
670 signature_expiry_sec: i64,
671 module_address: Address,
672 domain_separator: B256,
673 action_typehash: B256,
674 max_fee: Decimal,
675 limit_price: Decimal,
676 amount: Decimal,
677 order_type: DeriveOrderType,
678 time_in_force: DeriveTimeInForce,
679 trigger_fields: Option<DeriveTriggerFields>,
680) -> anyhow::Result<DeriveOrderParams> {
681 let direction = order_side_to_derive(order.order_side());
682
683 let asset_address: Address = instrument
684 .base_asset_address
685 .as_str()
686 .parse()
687 .with_context(|| {
688 format!(
689 "failed to parse base_asset_address `{}`",
690 instrument.base_asset_address.as_str(),
691 )
692 })?;
693 let sub_id =
694 U256::from_str_radix(instrument.base_asset_sub_id.as_str(), 10).with_context(|| {
695 format!(
696 "failed to parse base_asset_sub_id `{}`",
697 instrument.base_asset_sub_id.as_str(),
698 )
699 })?;
700
701 let trade = TradeModuleData {
702 asset_address,
703 sub_id,
704 limit_price,
705 amount,
706 max_fee,
707 recipient_id: subaccount_id,
708 is_bid: matches!(direction, DeriveOrderSide::Buy),
709 };
710
711 let ctx = ActionContext {
712 subaccount_id,
713 nonce,
714 module_address,
715 signature_expiry_sec,
716 owner: wallet,
717 signer: signer.address(),
718 };
719
720 let mut action = SignedAction::new(ctx, &trade, domain_separator, action_typehash);
721 action
722 .sign(signer)
723 .context("failed to sign Derive trade action")?;
724
725 Ok(DeriveOrderParams {
726 envelope: DeriveSignedEnvelope::from_signed_action(&action),
727 instrument_name: instrument.instrument_name,
728 direction,
729 order_type,
730 time_in_force,
731 limit_price,
732 amount,
733 max_fee,
734 label: order.client_order_id().to_string(),
735 referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
736 reduce_only: order.is_reduce_only().then_some(true),
737 mmp: order.is_post_only().then_some(false),
738 trigger_price: trigger_fields.map(|f| f.trigger_price),
739 trigger_price_type: trigger_fields.map(|f| f.trigger_price_type),
740 trigger_type: trigger_fields.map(|f| f.trigger_type),
741 })
742}
743
744#[cfg(test)]
745mod tests {
746 use std::time::{SystemTime, UNIX_EPOCH};
747
748 use nautilus_core::{UUID4, UnixNanos};
749 use nautilus_model::{
750 enums::{OrderSide, OrderType, TimeInForce, TriggerType},
751 identifiers::{ClientOrderId, InstrumentId, StrategyId, Symbol, TraderId},
752 orders::{LimitOrder, MarketOrder, OrderTestBuilder},
753 types::{Price, Quantity},
754 };
755 use rstest::rstest;
756 use rust_decimal_macros::dec;
757 use serde_json::Value;
758
759 use super::*;
760 use crate::common::{consts::DERIVE_VENUE, enums::DeriveInstrumentType};
761
762 fn canonical_wire<T: Serialize>(params: &T) -> String {
763 let mut value = serde_json::to_value(params).unwrap();
764 value.sort_all_objects();
765 serde_json::to_string(&value).unwrap()
766 }
767
768 fn to_value<T: Serialize>(value: T) -> Value {
769 serde_json::to_value(value).unwrap()
770 }
771
772 fn fixed_envelope(nonce: u64, signature: &str) -> DeriveSignedEnvelope {
773 DeriveSignedEnvelope {
774 subaccount_id: 30769,
775 nonce,
776 signer: "0xsigner".to_string(),
777 signature_expiry_sec: 1_700_000_600 + (nonce as i64 - 123_456),
778 signature: signature.to_string(),
779 }
780 }
781
782 #[rstest]
783 fn test_order_params_wire_round_trip_omits_unset_optionals() {
784 let params = DeriveOrderParams {
785 envelope: fixed_envelope(123_456, "0xabc"),
786 instrument_name: "ETH-PERP".into(),
787 direction: DeriveOrderSide::Buy,
788 order_type: DeriveOrderType::Limit,
789 time_in_force: DeriveTimeInForce::Gtc,
790 limit_price: dec!(3500.01),
791 amount: dec!(1.25),
792 max_fee: dec!(0.5),
793 label: "client-1".to_string(),
794 referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
795 reduce_only: None,
796 mmp: None,
797 trigger_price: None,
798 trigger_price_type: None,
799 trigger_type: None,
800 };
801
802 let wire = canonical_wire(¶ms);
803 let expected = include_str!("../../test_data/common/private_order_params_limit.json")
804 .trim_end_matches('\n');
805 let round_trip: DeriveOrderParams = serde_json::from_str(&wire).unwrap();
806
807 assert_eq!(wire, expected);
808 assert_eq!(round_trip, params);
809 }
810
811 #[rstest]
812 fn test_replace_params_wire_round_trip_includes_set_optionals() {
813 let params = DeriveReplaceParams {
814 order: DeriveOrderParams {
815 envelope: fixed_envelope(123_457, "0xdef"),
816 instrument_name: "ETH-PERP".into(),
817 direction: DeriveOrderSide::Sell,
818 order_type: DeriveOrderType::Limit,
819 time_in_force: DeriveTimeInForce::PostOnly,
820 limit_price: dec!(3499.5),
821 amount: dec!(2),
822 max_fee: dec!(0),
823 label: "client-2".to_string(),
824 referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
825 reduce_only: Some(true),
826 mmp: Some(false),
827 trigger_price: None,
828 trigger_price_type: None,
829 trigger_type: None,
830 },
831 order_id_to_cancel: "ord-stale-1".to_string(),
832 };
833
834 let wire = canonical_wire(¶ms);
835 let expected =
836 include_str!("../../test_data/common/private_replace_params_reduce_mmp.json")
837 .trim_end_matches('\n');
838 let round_trip: DeriveReplaceParams = serde_json::from_str(&wire).unwrap();
839
840 assert_eq!(wire, expected);
841 assert_eq!(round_trip, params);
842 }
843
844 #[rstest]
845 fn test_history_params_wire_round_trip_omits_unset_filters() {
846 let params = DeriveGetOrderHistoryParams::new(30769, 2, 500);
847
848 let wire = canonical_wire(¶ms);
849 let expected =
850 include_str!("../../test_data/common/private_order_history_params_required.json")
851 .trim_end_matches('\n');
852 let round_trip: DeriveGetOrderHistoryParams = serde_json::from_str(&wire).unwrap();
853
854 assert_eq!(wire, expected);
855 assert_eq!(round_trip, params);
856 }
857
858 #[rstest]
859 fn test_trigger_order_params_wire_round_trip_includes_trigger_fields() {
860 let params = DeriveTriggerOrderParams {
861 order: DeriveOrderParams {
862 envelope: fixed_envelope(123_458, "0xfeed"),
863 instrument_name: "ETH-PERP".into(),
864 direction: DeriveOrderSide::Sell,
865 order_type: DeriveOrderType::Market,
866 time_in_force: DeriveTimeInForce::Gtc,
867 limit_price: dec!(3400),
868 amount: dec!(0.1),
869 max_fee: dec!(0.5),
870 label: "client-stop-1".to_string(),
871 referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
872 reduce_only: Some(true),
873 mmp: None,
874 trigger_price: Some(dec!(3450)),
875 trigger_price_type: Some(DeriveTriggerPriceType::Mark),
876 trigger_type: Some(DeriveTriggerType::Stoploss),
877 },
878 conn_id: "conn-1".to_string(),
879 order_id: "trigger-order-1".to_string(),
880 };
881
882 let wire = canonical_wire(¶ms);
883 let expected =
884 include_str!("../../test_data/common/private_trigger_order_params_stop_market.json")
885 .trim_end_matches('\n');
886 let round_trip: DeriveTriggerOrderParams = serde_json::from_str(&wire).unwrap();
887
888 assert_eq!(wire, expected);
889 assert_eq!(round_trip, params);
890 }
891
892 fn sample_perp_instrument() -> DeriveInstrument {
893 DeriveInstrument {
896 amount_step: dec!(0.001),
897 base_asset_address: "0x000000000000000000000000000000000000abcd".into(),
898 base_asset_sub_id: "42".into(),
899 base_currency: "ETH".into(),
900 base_fee: dec!(0),
901 instrument_name: "ETH-PERP".into(),
902 instrument_type: DeriveInstrumentType::Perp,
903 is_active: true,
904 maker_fee_rate: dec!(0.0001),
905 mark_price_fee_rate_cap: None,
906 maximum_amount: dec!(1000),
907 minimum_amount: dec!(0.001),
908 option_details: None,
909 perp_details: None,
910 quote_currency: "USDC".into(),
911 scheduled_activation: 0,
912 scheduled_deactivation: 0,
913 taker_fee_rate: dec!(0.0005),
914 tick_size: dec!(0.01),
915 }
916 }
917
918 fn sample_signer() -> PrivateKeySigner {
919 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd"
920 .parse()
921 .unwrap()
922 }
923
924 fn sample_wallet() -> Address {
925 "0x000000000000000000000000000000000000aaaa"
926 .parse()
927 .unwrap()
928 }
929
930 fn sample_module() -> Address {
931 "0x000000000000000000000000000000000000bbbb"
932 .parse()
933 .unwrap()
934 }
935
936 fn sample_domain() -> B256 {
937 "0x2222222222222222222222222222222222222222222222222222222222222222"
938 .parse()
939 .unwrap()
940 }
941
942 fn sample_typehash() -> B256 {
943 "0x1111111111111111111111111111111111111111111111111111111111111111"
944 .parse()
945 .unwrap()
946 }
947
948 fn fresh_expiry_secs() -> i64 {
949 (SystemTime::now()
950 .duration_since(UNIX_EPOCH)
951 .unwrap()
952 .as_secs() as i64)
953 + 3600
954 }
955
956 fn build_test_limit_order(
957 side: OrderSide,
958 price: Decimal,
959 qty: Decimal,
960 post_only: bool,
961 reduce_only: bool,
962 ) -> OrderAny {
963 build_test_limit_order_with_time_in_force(
964 side,
965 price,
966 qty,
967 TimeInForce::Gtc,
968 post_only,
969 reduce_only,
970 )
971 }
972
973 fn build_test_limit_order_with_time_in_force(
974 side: OrderSide,
975 price: Decimal,
976 qty: Decimal,
977 time_in_force: TimeInForce,
978 post_only: bool,
979 reduce_only: bool,
980 ) -> OrderAny {
981 OrderAny::Limit(LimitOrder::new(
982 TraderId::from("TRADER-001"),
983 StrategyId::from("S-1"),
984 InstrumentId::new(Symbol::new("ETH-PERP"), *DERIVE_VENUE),
985 ClientOrderId::from("STRAT-PAYLOAD-1"),
986 side,
987 Quantity::from_decimal(qty).unwrap(),
988 Price::from_decimal(price).unwrap(),
989 time_in_force,
990 None,
991 post_only,
992 reduce_only,
993 false,
994 None,
995 None,
996 None,
997 None,
998 None,
999 None,
1000 None,
1001 None,
1002 None,
1003 None,
1004 None,
1005 UUID4::new(),
1006 UnixNanos::default(),
1007 ))
1008 }
1009
1010 fn build_test_stop_market_order() -> OrderAny {
1011 build_test_trigger_order(
1012 OrderType::StopMarket,
1013 OrderSide::Buy,
1014 None,
1015 TriggerType::Default,
1016 )
1017 }
1018
1019 fn build_test_trigger_order(
1020 order_type: OrderType,
1021 side: OrderSide,
1022 price: Option<Decimal>,
1023 trigger_type: TriggerType,
1024 ) -> OrderAny {
1025 let mut builder = OrderTestBuilder::new(order_type);
1026 builder
1027 .instrument_id(InstrumentId::new(Symbol::new("ETH-PERP"), *DERIVE_VENUE))
1028 .client_order_id(ClientOrderId::from("STRAT-PAYLOAD-STOP"))
1029 .side(side)
1030 .quantity(Quantity::from_decimal(dec!(1)).unwrap())
1031 .trigger_price(Price::from_decimal(dec!(3600)).unwrap())
1032 .trigger_type(trigger_type)
1033 .time_in_force(TimeInForce::Gtc);
1034
1035 if let Some(price) = price {
1036 builder.price(Price::from_decimal(price).unwrap());
1037 }
1038
1039 builder.build()
1040 }
1041
1042 fn build_test_market_order(side: OrderSide, qty: Decimal) -> OrderAny {
1043 OrderAny::Market(MarketOrder::new(
1044 TraderId::from("TRADER-001"),
1045 StrategyId::from("S-1"),
1046 InstrumentId::new(Symbol::new("ETH-PERP"), *DERIVE_VENUE),
1047 ClientOrderId::from("STRAT-PAYLOAD-MK"),
1048 side,
1049 Quantity::from_decimal(qty).unwrap(),
1050 TimeInForce::Ioc,
1051 UUID4::new(),
1052 UnixNanos::default(),
1053 false,
1054 false,
1055 None,
1056 None,
1057 None,
1058 None,
1059 None,
1060 None,
1061 None,
1062 None,
1063 ))
1064 }
1065
1066 #[rstest]
1067 fn test_order_to_derive_payload_limit_carries_all_required_fields() {
1068 let order = build_test_limit_order(OrderSide::Buy, dec!(3500), dec!(1), false, false);
1069 let instrument = sample_perp_instrument();
1070 let signer = sample_signer();
1071 let payload = order_to_derive_payload(
1072 &order,
1073 &instrument,
1074 30769,
1075 sample_wallet(),
1076 &signer,
1077 17_000_000_000_001,
1078 fresh_expiry_secs(),
1079 sample_module(),
1080 sample_domain(),
1081 sample_typehash(),
1082 dec!(1),
1083 None,
1084 )
1085 .map(to_value)
1086 .expect("payload built");
1087
1088 assert_eq!(payload["instrument_name"], "ETH-PERP");
1089 assert_eq!(payload["direction"], "buy");
1090 assert_eq!(payload["order_type"], "limit");
1091 assert_eq!(payload["time_in_force"], "gtc");
1092 assert_eq!(payload["label"], "STRAT-PAYLOAD-1");
1093 assert_eq!(payload["referral_code"], "nautilus");
1094 assert_eq!(payload["limit_price"], "3500");
1095 assert_eq!(payload["amount"], "1");
1096 assert_eq!(payload["max_fee"], "1");
1097 assert_eq!(payload["subaccount_id"], 30769);
1098 assert_eq!(payload["nonce"], 17_000_000_000_001_u64);
1099 assert!(payload["signature_expiry_sec"].as_i64().unwrap() > 0);
1100 let signature = payload["signature"].as_str().unwrap();
1101 assert!(signature.starts_with("0x"));
1102 assert_eq!(signature.len(), 2 + 130, "65-byte sig = 132 hex chars");
1103 assert!(payload.get("reduce_only").is_none());
1104 assert!(payload.get("mmp").is_none());
1105 }
1106
1107 #[rstest]
1108 fn test_order_to_derive_payload_accepts_uint256_sub_id() {
1109 let order = build_test_limit_order(OrderSide::Buy, dec!(1), dec!(0.1), true, false);
1110 let mut instrument = sample_perp_instrument();
1111 instrument.instrument_name = "ETH-20260529-2200-C".into();
1112 instrument.base_asset_sub_id = "39614082202024973918552016768".into();
1113 instrument.instrument_type = DeriveInstrumentType::Option;
1114 let signer = sample_signer();
1115 let payload = order_to_derive_payload(
1116 &order,
1117 &instrument,
1118 30769,
1119 sample_wallet(),
1120 &signer,
1121 17_000_000_000_002,
1122 fresh_expiry_secs(),
1123 sample_module(),
1124 sample_domain(),
1125 sample_typehash(),
1126 dec!(1),
1127 None,
1128 )
1129 .map(to_value)
1130 .expect("payload built");
1131
1132 assert_eq!(payload["instrument_name"], "ETH-20260529-2200-C");
1133 assert_eq!(payload["time_in_force"], "post_only");
1134 assert!(payload["signature"].as_str().unwrap().starts_with("0x"));
1135 }
1136
1137 #[rstest]
1138 #[case(TimeInForce::Gtc, false, "gtc")]
1139 #[case(TimeInForce::Ioc, false, "ioc")]
1140 #[case(TimeInForce::Fok, false, "fok")]
1141 #[case(TimeInForce::Gtc, true, "post_only")]
1142 fn test_order_to_derive_payload_carries_supported_time_in_force(
1143 #[case] time_in_force: TimeInForce,
1144 #[case] post_only: bool,
1145 #[case] expected: &str,
1146 ) {
1147 let order = build_test_limit_order_with_time_in_force(
1148 OrderSide::Buy,
1149 dec!(3500),
1150 dec!(1),
1151 time_in_force,
1152 post_only,
1153 false,
1154 );
1155 let instrument = sample_perp_instrument();
1156 let signer = sample_signer();
1157 let payload = order_to_derive_payload(
1158 &order,
1159 &instrument,
1160 30769,
1161 sample_wallet(),
1162 &signer,
1163 17_000_000_000_002,
1164 fresh_expiry_secs(),
1165 sample_module(),
1166 sample_domain(),
1167 sample_typehash(),
1168 dec!(0),
1169 None,
1170 )
1171 .map(to_value)
1172 .expect("payload built");
1173
1174 assert_eq!(payload["time_in_force"], expected);
1175 }
1176
1177 #[rstest]
1178 fn test_order_to_derive_payload_emits_reduce_only_and_mmp_flags_when_set() {
1179 let order = build_test_limit_order(OrderSide::Sell, dec!(3500), dec!(1), true, true);
1180 let instrument = sample_perp_instrument();
1181 let signer = sample_signer();
1182 let payload = order_to_derive_payload(
1183 &order,
1184 &instrument,
1185 30769,
1186 sample_wallet(),
1187 &signer,
1188 17_000_000_000_002,
1189 fresh_expiry_secs(),
1190 sample_module(),
1191 sample_domain(),
1192 sample_typehash(),
1193 dec!(0),
1194 None,
1195 )
1196 .map(to_value)
1197 .expect("payload built");
1198
1199 assert_eq!(payload["direction"], "sell");
1200 assert_eq!(payload["time_in_force"], "post_only");
1201 assert_eq!(payload["reduce_only"], true);
1202 assert_eq!(payload["mmp"], false);
1203 }
1204
1205 #[rstest]
1206 fn test_order_to_derive_payload_market_uses_explicit_price_override() {
1207 let order = build_test_market_order(OrderSide::Buy, dec!(0.5));
1208 let instrument = sample_perp_instrument();
1209 let signer = sample_signer();
1210 let payload = order_to_derive_payload(
1211 &order,
1212 &instrument,
1213 30769,
1214 sample_wallet(),
1215 &signer,
1216 17_000_000_000_003,
1217 fresh_expiry_secs(),
1218 sample_module(),
1219 sample_domain(),
1220 sample_typehash(),
1221 dec!(0),
1222 Some(dec!(3519)),
1223 )
1224 .map(to_value)
1225 .expect("payload built");
1226
1227 assert_eq!(payload["order_type"], "market");
1228 assert_eq!(payload["limit_price"], "3519");
1229 }
1230
1231 #[rstest]
1232 fn test_order_to_derive_payload_market_without_explicit_price_errors() {
1233 let order = build_test_market_order(OrderSide::Buy, dec!(0.5));
1234 let instrument = sample_perp_instrument();
1235 let signer = sample_signer();
1236 let err = order_to_derive_payload(
1237 &order,
1238 &instrument,
1239 30769,
1240 sample_wallet(),
1241 &signer,
1242 17_000_000_000_004,
1243 fresh_expiry_secs(),
1244 sample_module(),
1245 sample_domain(),
1246 sample_typehash(),
1247 dec!(0),
1248 None,
1249 )
1250 .expect_err("market without price must error");
1251
1252 assert!(
1253 err.to_string().contains("missing limit price"),
1254 "unexpected error: {err}",
1255 );
1256 }
1257
1258 #[rstest]
1259 #[case(TimeInForce::Day, false, "unsupported time in force")]
1260 #[case(TimeInForce::Day, true, "unsupported time in force")]
1261 #[case(TimeInForce::Ioc, true, "post-only Derive orders only support GTC")]
1262 #[case(TimeInForce::Fok, true, "post-only Derive orders only support GTC")]
1263 fn test_order_to_derive_payload_rejects_unsupported_tif(
1264 #[case] time_in_force: TimeInForce,
1265 #[case] post_only: bool,
1266 #[case] reason_fragment: &str,
1267 ) {
1268 let order = build_test_limit_order_with_time_in_force(
1269 OrderSide::Buy,
1270 dec!(3500),
1271 dec!(1),
1272 time_in_force,
1273 post_only,
1274 false,
1275 );
1276 let instrument = sample_perp_instrument();
1277 let signer = sample_signer();
1278 let err = order_to_derive_payload(
1279 &order,
1280 &instrument,
1281 30769,
1282 sample_wallet(),
1283 &signer,
1284 17_000_000_000_005,
1285 fresh_expiry_secs(),
1286 sample_module(),
1287 sample_domain(),
1288 sample_typehash(),
1289 dec!(0),
1290 None,
1291 )
1292 .expect_err("unsupported TIF must error");
1293
1294 assert!(
1295 err.to_string().contains(reason_fragment),
1296 "unexpected error: {err}",
1297 );
1298 }
1299
1300 #[rstest]
1301 fn test_order_to_derive_payload_rejects_stop_order_before_price_resolution() {
1302 let order = build_test_stop_market_order();
1303 let instrument = sample_perp_instrument();
1304 let signer = sample_signer();
1305 let err = order_to_derive_payload(
1306 &order,
1307 &instrument,
1308 30769,
1309 sample_wallet(),
1310 &signer,
1311 17_000_000_000_006,
1312 fresh_expiry_secs(),
1313 sample_module(),
1314 sample_domain(),
1315 sample_typehash(),
1316 dec!(0),
1317 None,
1318 )
1319 .expect_err("unsupported order type must error");
1320
1321 assert!(
1322 err.to_string().contains("unsupported order type"),
1323 "unexpected error: {err}",
1324 );
1325 assert!(
1326 !err.to_string().contains("missing limit price"),
1327 "unexpected error: {err}",
1328 );
1329 }
1330
1331 #[rstest]
1332 fn test_trigger_order_to_derive_payload_stop_market_uses_mark_trigger() {
1333 let order = build_test_trigger_order(
1334 OrderType::StopMarket,
1335 OrderSide::Sell,
1336 None,
1337 TriggerType::MarkPrice,
1338 );
1339 let instrument = sample_perp_instrument();
1340 let signer = sample_signer();
1341 let payload = trigger_order_to_derive_payload(
1342 &order,
1343 &instrument,
1344 30769,
1345 sample_wallet(),
1346 &signer,
1347 17_000_000_000_007,
1348 fresh_expiry_secs(),
1349 sample_module(),
1350 sample_domain(),
1351 sample_typehash(),
1352 dec!(1),
1353 Some(dec!(3400)),
1354 "conn-1",
1355 "trigger-1",
1356 )
1357 .map(to_value)
1358 .expect("trigger payload built");
1359
1360 assert_eq!(payload["conn_id"], "conn-1");
1361 assert_eq!(payload["order_id"], "trigger-1");
1362 assert_eq!(payload["direction"], "sell");
1363 assert_eq!(payload["order_type"], "market");
1364 assert_eq!(payload["limit_price"], "3400");
1365 assert_eq!(payload["trigger_price"], "3600");
1366 assert_eq!(payload["trigger_price_type"], "mark");
1367 assert_eq!(payload["trigger_type"], "stoploss");
1368 }
1369
1370 #[rstest]
1371 fn test_trigger_order_to_derive_payload_stop_limit_maps_limit_stoploss() {
1372 let order = build_test_trigger_order(
1373 OrderType::StopLimit,
1374 OrderSide::Sell,
1375 Some(dec!(3500)),
1376 TriggerType::MarkPrice,
1377 );
1378 let instrument = sample_perp_instrument();
1379 let signer = sample_signer();
1380 let payload = trigger_order_to_derive_payload(
1381 &order,
1382 &instrument,
1383 30769,
1384 sample_wallet(),
1385 &signer,
1386 17_000_000_000_008,
1387 fresh_expiry_secs(),
1388 sample_module(),
1389 sample_domain(),
1390 sample_typehash(),
1391 dec!(1),
1392 None,
1393 "conn-1",
1394 "trigger-2",
1395 )
1396 .map(to_value)
1397 .expect("trigger payload built");
1398
1399 assert_eq!(payload["order_type"], "limit");
1400 assert_eq!(payload["limit_price"], "3500");
1401 assert_eq!(payload["trigger_type"], "stoploss");
1402 }
1403
1404 #[rstest]
1405 #[case(
1406 OrderType::MarketIfTouched,
1407 DeriveOrderType::Market,
1408 DeriveTriggerType::Takeprofit
1409 )]
1410 #[case(
1411 OrderType::LimitIfTouched,
1412 DeriveOrderType::Limit,
1413 DeriveTriggerType::Takeprofit
1414 )]
1415 fn test_trigger_order_to_derive_payload_take_profit_types(
1416 #[case] order_type: OrderType,
1417 #[case] expected_order_type: DeriveOrderType,
1418 #[case] expected_trigger_type: DeriveTriggerType,
1419 ) {
1420 let price = if order_type == OrderType::LimitIfTouched {
1421 Some(dec!(3700))
1422 } else {
1423 None
1424 };
1425 let explicit_price = if price.is_none() {
1426 Some(dec!(3705))
1427 } else {
1428 None
1429 };
1430 let order =
1431 build_test_trigger_order(order_type, OrderSide::Buy, price, TriggerType::MarkPrice);
1432 let instrument = sample_perp_instrument();
1433 let signer = sample_signer();
1434 let payload = trigger_order_to_derive_payload(
1435 &order,
1436 &instrument,
1437 30769,
1438 sample_wallet(),
1439 &signer,
1440 17_000_000_000_009,
1441 fresh_expiry_secs(),
1442 sample_module(),
1443 sample_domain(),
1444 sample_typehash(),
1445 dec!(1),
1446 explicit_price,
1447 "conn-1",
1448 "trigger-3",
1449 )
1450 .expect("trigger payload built");
1451
1452 assert_eq!(payload.order.order_type, expected_order_type);
1453 assert_eq!(payload.order.trigger_type, Some(expected_trigger_type));
1454 }
1455
1456 #[rstest]
1457 fn test_trigger_order_to_derive_payload_rejects_index_trigger_price_type() {
1458 let order = build_test_trigger_order(
1459 OrderType::StopMarket,
1460 OrderSide::Buy,
1461 None,
1462 TriggerType::IndexPrice,
1463 );
1464 let instrument = sample_perp_instrument();
1465 let signer = sample_signer();
1466 let err = trigger_order_to_derive_payload(
1467 &order,
1468 &instrument,
1469 30769,
1470 sample_wallet(),
1471 &signer,
1472 17_000_000_000_010,
1473 fresh_expiry_secs(),
1474 sample_module(),
1475 sample_domain(),
1476 sample_typehash(),
1477 dec!(1),
1478 Some(dec!(3618)),
1479 "conn-1",
1480 "trigger-4",
1481 )
1482 .expect_err("index trigger price type must fail");
1483
1484 assert!(
1485 err.to_string()
1486 .contains("Derive currently accepts only MarkPrice"),
1487 "unexpected error: {err}",
1488 );
1489 }
1490
1491 #[rstest]
1492 fn test_trigger_order_to_derive_payload_maps_default_trigger_type_to_mark() {
1493 let order = build_test_trigger_order(
1494 OrderType::StopMarket,
1495 OrderSide::Buy,
1496 None,
1497 TriggerType::Default,
1498 );
1499 let instrument = sample_perp_instrument();
1500 let signer = sample_signer();
1501 let payload = trigger_order_to_derive_payload(
1502 &order,
1503 &instrument,
1504 30769,
1505 sample_wallet(),
1506 &signer,
1507 17_000_000_000_011,
1508 fresh_expiry_secs(),
1509 sample_module(),
1510 sample_domain(),
1511 sample_typehash(),
1512 dec!(1),
1513 Some(dec!(3618)),
1514 "conn-1",
1515 "trigger-5",
1516 )
1517 .expect("default trigger type should map to mark");
1518
1519 assert_eq!(
1520 payload.order.trigger_price_type,
1521 Some(DeriveTriggerPriceType::Mark),
1522 );
1523 }
1524
1525 #[rstest]
1526 fn test_order_replace_to_derive_payload_stamps_cancel_clause_and_overrides() {
1527 let order = build_test_limit_order(OrderSide::Buy, dec!(3500), dec!(1), false, false);
1528 let instrument = sample_perp_instrument();
1529 let signer = sample_signer();
1530 let payload = order_replace_to_derive_payload(
1531 &order,
1532 &instrument,
1533 30769,
1534 sample_wallet(),
1535 &signer,
1536 17_000_000_000_010,
1537 fresh_expiry_secs(),
1538 sample_module(),
1539 sample_domain(),
1540 sample_typehash(),
1541 dec!(1),
1542 Some(dec!(2)),
1543 Some(dec!(3505)),
1544 "ord-stale-1",
1545 )
1546 .map(to_value)
1547 .expect("replace payload built");
1548
1549 assert_eq!(payload["order_id_to_cancel"], "ord-stale-1");
1550 assert_eq!(payload["amount"], "2");
1551 assert_eq!(payload["limit_price"], "3505");
1552 assert_eq!(payload["direction"], "buy");
1553 assert_eq!(payload["order_type"], "limit");
1554 assert_eq!(payload["time_in_force"], "gtc");
1555 assert_eq!(payload["label"], "STRAT-PAYLOAD-1");
1556 assert_eq!(payload["subaccount_id"], 30769);
1557 assert_eq!(payload["nonce"], 17_000_000_000_010_u64);
1558 let signature = payload["signature"].as_str().unwrap();
1559 assert!(signature.starts_with("0x"));
1560 assert_eq!(signature.len(), 2 + 130);
1561 }
1562
1563 #[rstest]
1564 fn test_order_replace_to_derive_payload_falls_back_to_cached_quantity_and_price() {
1565 let order = build_test_limit_order(OrderSide::Sell, dec!(3501), dec!(0.5), false, false);
1566 let instrument = sample_perp_instrument();
1567 let signer = sample_signer();
1568 let payload = order_replace_to_derive_payload(
1569 &order,
1570 &instrument,
1571 30769,
1572 sample_wallet(),
1573 &signer,
1574 17_000_000_000_011,
1575 fresh_expiry_secs(),
1576 sample_module(),
1577 sample_domain(),
1578 sample_typehash(),
1579 dec!(0),
1580 None,
1581 None,
1582 "ord-stale-2",
1583 )
1584 .map(to_value)
1585 .expect("replace payload built");
1586
1587 assert_eq!(payload["order_id_to_cancel"], "ord-stale-2");
1588 assert_eq!(payload["amount"], "0.5");
1589 assert_eq!(payload["limit_price"], "3501");
1590 assert_eq!(payload["direction"], "sell");
1591 }
1592
1593 #[rstest]
1594 fn test_order_replace_to_derive_payload_market_without_explicit_price_errors() {
1595 let order = build_test_market_order(OrderSide::Buy, dec!(0.5));
1596 let instrument = sample_perp_instrument();
1597 let signer = sample_signer();
1598 let err = order_replace_to_derive_payload(
1599 &order,
1600 &instrument,
1601 30769,
1602 sample_wallet(),
1603 &signer,
1604 17_000_000_000_012,
1605 fresh_expiry_secs(),
1606 sample_module(),
1607 sample_domain(),
1608 sample_typehash(),
1609 dec!(0),
1610 None,
1611 None,
1612 "ord-stale-3",
1613 )
1614 .expect_err("market replace without price must error");
1615
1616 assert!(
1617 err.to_string().contains("missing limit price"),
1618 "unexpected error: {err}",
1619 );
1620 }
1621
1622 #[rstest]
1623 fn test_order_replace_to_derive_payload_rejects_stop_order_before_price_resolution() {
1624 let order = build_test_stop_market_order();
1625 let instrument = sample_perp_instrument();
1626 let signer = sample_signer();
1627 let err = order_replace_to_derive_payload(
1628 &order,
1629 &instrument,
1630 30769,
1631 sample_wallet(),
1632 &signer,
1633 17_000_000_000_013,
1634 fresh_expiry_secs(),
1635 sample_module(),
1636 sample_domain(),
1637 sample_typehash(),
1638 dec!(0),
1639 None,
1640 None,
1641 "ord-stale-4",
1642 )
1643 .expect_err("unsupported order type must error");
1644
1645 assert!(
1646 err.to_string().contains("unsupported order type"),
1647 "unexpected error: {err}",
1648 );
1649 assert!(
1650 !err.to_string().contains("missing limit price"),
1651 "unexpected error: {err}",
1652 );
1653 }
1654}