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