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