1use std::collections::HashSet;
19
20use alloy::signers::local::PrivateKeySigner;
21use alloy_primitives::{Address, B256, U256};
22use anyhow::Context;
23#[cfg(test)]
24use nautilus_core::string::secret::REDACTED;
25use nautilus_core::{
26 serialization::{
27 deserialize_decimal, serialize_decimal_as_str, serialize_optional_decimal_as_str,
28 },
29 string::secret::SecretString,
30};
31use nautilus_model::{
32 events::OrderDeniedReason,
33 orders::{Order, OrderAny},
34};
35use rust_decimal::Decimal;
36use serde::{Deserialize, Serialize};
37use ustr::Ustr;
38
39use crate::{
40 common::{
41 consts::DERIVE_NAUTILUS_REFERRAL_CODE,
42 enums::{
43 DeriveOrderSide, DeriveOrderType, DeriveTimeInForce, DeriveTriggerPriceType,
44 DeriveTriggerType,
45 },
46 parse::{
47 order_side_to_derive, order_type_to_derive, time_in_force_to_derive,
48 trigger_order_type_to_derive, trigger_price_type_to_derive, trigger_type_to_derive,
49 },
50 },
51 http::{
52 error::{DeriveHttpError, Result as HttpResult},
53 models::{DeriveInstrument, DerivePaginationInfo},
54 },
55 signing::{
56 eip712::{ActionContext, SignedAction},
57 modules::{ModuleData, trade::TradeModuleData},
58 nonce::{deserialize_nonce, serialize_nonce},
59 },
60};
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
64pub struct DeriveSignedEnvelope {
65 pub subaccount_id: u64,
67 #[serde(
69 serialize_with = "serialize_nonce",
70 deserialize_with = "deserialize_nonce"
71 )]
72 pub nonce: u64,
73 pub signer: String,
75 pub signature_expiry_sec: i64,
77 pub signature: SecretString,
79}
80
81impl DeriveSignedEnvelope {
82 #[must_use]
83 pub fn from_signed_action<M: ModuleData>(action: &SignedAction<'_, M>) -> Self {
84 Self {
85 subaccount_id: action.subaccount_id(),
86 nonce: action.nonce(),
87 signer: format!("{:?}", action.signer_address()),
88 signature_expiry_sec: action.signature_expiry_sec(),
89 signature: SecretString::from(action.signature_hex()),
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
96pub struct DeriveOrderParams {
97 #[serde(flatten)]
99 pub envelope: DeriveSignedEnvelope,
100 pub instrument_name: Ustr,
102 pub direction: DeriveOrderSide,
104 pub order_type: DeriveOrderType,
106 pub time_in_force: DeriveTimeInForce,
108 #[serde(
110 serialize_with = "serialize_decimal_as_str",
111 deserialize_with = "deserialize_decimal"
112 )]
113 pub limit_price: Decimal,
114 #[serde(
116 serialize_with = "serialize_decimal_as_str",
117 deserialize_with = "deserialize_decimal"
118 )]
119 pub amount: Decimal,
120 #[serde(
122 serialize_with = "serialize_decimal_as_str",
123 deserialize_with = "deserialize_decimal"
124 )]
125 pub max_fee: Decimal,
126 pub label: String,
128 pub referral_code: String,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub reduce_only: Option<bool>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub mmp: Option<bool>,
136 #[serde(
138 default,
139 skip_serializing_if = "Option::is_none",
140 serialize_with = "serialize_optional_decimal_as_str",
141 deserialize_with = "nautilus_core::serialization::deserialize_optional_decimal"
142 )]
143 pub trigger_price: Option<Decimal>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub trigger_price_type: Option<DeriveTriggerPriceType>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub trigger_type: Option<DeriveTriggerType>,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
154pub struct DeriveTriggerOrderParams {
155 #[serde(flatten)]
157 pub order: DeriveOrderParams,
158 #[serde(skip)]
160 pub conn_id: String,
161 #[serde(skip)]
163 pub order_id: String,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
168pub struct DeriveReplaceParams {
169 #[serde(flatten)]
171 pub order: DeriveOrderParams,
172 pub order_id_to_cancel: String,
174 #[serde(
177 default,
178 skip_serializing_if = "Option::is_none",
179 serialize_with = "serialize_optional_decimal_as_str",
180 deserialize_with = "nautilus_core::serialization::deserialize_optional_decimal"
181 )]
182 pub expected_filled_amount: Option<Decimal>,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
187pub struct DeriveCancelTriggerOrderParams {
188 pub subaccount_id: u64,
190 pub order_id: String,
192}
193
194impl DeriveCancelTriggerOrderParams {
195 #[must_use]
196 pub fn new(subaccount_id: u64, order_id: impl Into<String>) -> Self {
197 Self {
198 subaccount_id,
199 order_id: order_id.into(),
200 }
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
206pub struct DeriveCancelParams {
207 pub subaccount_id: u64,
209 pub instrument_name: Ustr,
211 pub order_id: String,
213}
214
215impl DeriveCancelParams {
216 #[must_use]
217 pub fn new(
218 subaccount_id: u64,
219 instrument_name: impl Into<Ustr>,
220 order_id: impl Into<String>,
221 ) -> Self {
222 Self {
223 subaccount_id,
224 instrument_name: instrument_name.into(),
225 order_id: order_id.into(),
226 }
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
232pub struct DeriveCancelByInstrumentParams {
233 pub subaccount_id: u64,
235 pub instrument_name: Ustr,
237}
238
239impl DeriveCancelByInstrumentParams {
240 #[must_use]
241 pub fn new(subaccount_id: u64, instrument_name: impl Into<Ustr>) -> Self {
242 Self {
243 subaccount_id,
244 instrument_name: instrument_name.into(),
245 }
246 }
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
251pub struct DeriveCancelAllParams {
252 pub subaccount_id: u64,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub instrument_name: Option<Ustr>,
257}
258
259impl DeriveCancelAllParams {
260 #[must_use]
261 pub const fn new(subaccount_id: u64) -> Self {
262 Self {
263 subaccount_id,
264 instrument_name: None,
265 }
266 }
267
268 #[must_use]
269 pub fn with_instrument_name(mut self, instrument_name: impl Into<Ustr>) -> Self {
270 self.instrument_name = Some(instrument_name.into());
271 self
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
277pub struct DeriveCancelByLabelParams {
278 pub subaccount_id: u64,
280 pub label: String,
282}
283
284impl DeriveCancelByLabelParams {
285 #[must_use]
286 pub fn new(subaccount_id: u64, label: impl Into<String>) -> Self {
287 Self {
288 subaccount_id,
289 label: label.into(),
290 }
291 }
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
296pub struct DeriveGetSubaccountParams {
297 pub subaccount_id: u64,
299}
300
301impl DeriveGetSubaccountParams {
302 #[must_use]
303 pub const fn new(subaccount_id: u64) -> Self {
304 Self { subaccount_id }
305 }
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
310pub struct DeriveGetOpenOrdersParams {
311 pub subaccount_id: u64,
313}
314
315impl DeriveGetOpenOrdersParams {
316 #[must_use]
317 pub const fn new(subaccount_id: u64) -> Self {
318 Self { subaccount_id }
319 }
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
324pub struct DeriveGetTriggerOrdersParams {
325 pub subaccount_id: u64,
327}
328
329impl DeriveGetTriggerOrdersParams {
330 #[must_use]
331 pub const fn new(subaccount_id: u64) -> Self {
332 Self { subaccount_id }
333 }
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
338pub struct DeriveGetOrderParams {
339 pub subaccount_id: u64,
341 pub order_id: String,
343}
344
345impl DeriveGetOrderParams {
346 #[must_use]
347 pub fn new(subaccount_id: u64, order_id: impl Into<String>) -> Self {
348 Self {
349 subaccount_id,
350 order_id: order_id.into(),
351 }
352 }
353}
354
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
357pub struct DeriveGetOrderHistoryParams {
358 pub subaccount_id: u64,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub instrument_name: Option<Ustr>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub from_timestamp: Option<i64>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub to_timestamp: Option<i64>,
369 pub page: u32,
371 pub page_size: u32,
373}
374
375impl DeriveGetOrderHistoryParams {
376 #[must_use]
377 pub fn new(subaccount_id: u64, page: u32, page_size: u32) -> Self {
378 Self {
379 subaccount_id,
380 instrument_name: None,
381 from_timestamp: None,
382 to_timestamp: None,
383 page,
384 page_size,
385 }
386 }
387
388 #[must_use]
389 pub fn with_instrument_name(mut self, instrument_name: impl Into<Ustr>) -> Self {
390 self.instrument_name = Some(instrument_name.into());
391 self
392 }
393
394 #[must_use]
395 pub const fn with_window(
396 mut self,
397 from_timestamp: Option<i64>,
398 to_timestamp: Option<i64>,
399 ) -> Self {
400 self.from_timestamp = from_timestamp;
401 self.to_timestamp = to_timestamp;
402 self
403 }
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
408pub struct DeriveGetTradeHistoryParams {
409 pub subaccount_id: u64,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub instrument_name: Option<Ustr>,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub from_timestamp: Option<i64>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub to_timestamp: Option<i64>,
420 pub page: u32,
422 pub page_size: u32,
424}
425
426impl DeriveGetTradeHistoryParams {
427 #[must_use]
428 pub fn new(subaccount_id: u64, page: u32, page_size: u32) -> Self {
429 Self {
430 subaccount_id,
431 instrument_name: None,
432 from_timestamp: None,
433 to_timestamp: None,
434 page,
435 page_size,
436 }
437 }
438
439 #[must_use]
440 pub fn with_instrument_name(mut self, instrument_name: impl Into<Ustr>) -> Self {
441 self.instrument_name = Some(instrument_name.into());
442 self
443 }
444
445 #[must_use]
446 pub const fn with_window(
447 mut self,
448 from_timestamp: Option<i64>,
449 to_timestamp: Option<i64>,
450 ) -> Self {
451 self.from_timestamp = from_timestamp;
452 self.to_timestamp = to_timestamp;
453 self
454 }
455}
456
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
459pub struct DeriveGetPositionsParams {
460 pub subaccount_id: u64,
462}
463
464impl DeriveGetPositionsParams {
465 #[must_use]
466 pub const fn new(subaccount_id: u64) -> Self {
467 Self { subaccount_id }
468 }
469}
470
471#[expect(clippy::too_many_arguments)]
486pub fn order_to_derive_payload(
487 order: &OrderAny,
488 instrument: &DeriveInstrument,
489 subaccount_id: u64,
490 wallet: Address,
491 signer: &PrivateKeySigner,
492 nonce: u64,
493 signature_expiry_sec: i64,
494 module_address: Address,
495 domain_separator: B256,
496 action_typehash: B256,
497 max_fee: Decimal,
498 explicit_price: Option<Decimal>,
499) -> anyhow::Result<DeriveOrderParams> {
500 validate_order_support(order)?;
501 let limit_price = resolve_limit_price(order, explicit_price)?;
502 let amount = order.quantity().as_decimal();
503 let order_type = order_type_to_derive(order.order_type())?;
504 let time_in_force = time_in_force_to_derive(order.time_in_force(), order.is_post_only())?;
505 build_signed_order_params(
506 order,
507 instrument,
508 subaccount_id,
509 wallet,
510 signer,
511 nonce,
512 signature_expiry_sec,
513 module_address,
514 domain_separator,
515 action_typehash,
516 max_fee,
517 limit_price,
518 amount,
519 order_type,
520 time_in_force,
521 None,
522 )
523}
524
525#[expect(clippy::too_many_arguments)]
536pub fn trigger_order_to_derive_payload(
537 order: &OrderAny,
538 instrument: &DeriveInstrument,
539 subaccount_id: u64,
540 wallet: Address,
541 signer: &PrivateKeySigner,
542 nonce: u64,
543 signature_expiry_sec: i64,
544 module_address: Address,
545 domain_separator: B256,
546 action_typehash: B256,
547 max_fee: Decimal,
548 explicit_price: Option<Decimal>,
549 conn_id: impl Into<String>,
550 order_id: impl Into<String>,
551) -> anyhow::Result<DeriveTriggerOrderParams> {
552 validate_trigger_order_support(order)?;
553 let limit_price = resolve_limit_price(order, explicit_price)?;
554 let amount = order.quantity().as_decimal();
555 let order_type = trigger_order_type_to_derive(order.order_type())?;
556 let time_in_force = time_in_force_to_derive(order.time_in_force(), order.is_post_only())?;
557
558 let trigger_price = order.trigger_price().ok_or_else(|| {
559 anyhow::anyhow!(
560 "missing trigger price for Derive trigger order {}",
561 order.client_order_id()
562 )
563 })?;
564
565 let trigger_fields = DeriveTriggerFields {
566 trigger_price: trigger_price.as_decimal(),
567 trigger_price_type: trigger_price_type_to_derive(order.trigger_type())?,
568 trigger_type: trigger_type_to_derive(order.order_type())?,
569 };
570
571 let order = build_signed_order_params(
572 order,
573 instrument,
574 subaccount_id,
575 wallet,
576 signer,
577 nonce,
578 signature_expiry_sec,
579 module_address,
580 domain_separator,
581 action_typehash,
582 max_fee,
583 limit_price,
584 amount,
585 order_type,
586 time_in_force,
587 Some(trigger_fields),
588 )?;
589
590 Ok(DeriveTriggerOrderParams {
591 order,
592 conn_id: conn_id.into(),
593 order_id: order_id.into(),
594 })
595}
596
597#[expect(clippy::too_many_arguments)]
609pub fn order_replace_to_derive_payload(
610 order: &OrderAny,
611 instrument: &DeriveInstrument,
612 subaccount_id: u64,
613 wallet: Address,
614 signer: &PrivateKeySigner,
615 nonce: u64,
616 signature_expiry_sec: i64,
617 module_address: Address,
618 domain_separator: B256,
619 action_typehash: B256,
620 max_fee: Decimal,
621 explicit_quantity: Option<Decimal>,
622 explicit_price: Option<Decimal>,
623 order_id_to_cancel: &str,
624) -> anyhow::Result<DeriveReplaceParams> {
625 validate_order_support(order)?;
626 let limit_price = resolve_limit_price(order, explicit_price)?;
627 let amount = explicit_quantity.unwrap_or_else(|| order.quantity().as_decimal());
628 let order_type = order_type_to_derive(order.order_type())?;
629 let time_in_force = time_in_force_to_derive(order.time_in_force(), order.is_post_only())?;
630 let order = build_signed_order_params(
631 order,
632 instrument,
633 subaccount_id,
634 wallet,
635 signer,
636 nonce,
637 signature_expiry_sec,
638 module_address,
639 domain_separator,
640 action_typehash,
641 max_fee,
642 limit_price,
643 amount,
644 order_type,
645 time_in_force,
646 None,
647 )?;
648
649 Ok(DeriveReplaceParams {
650 order,
651 order_id_to_cancel: order_id_to_cancel.to_string(),
652 expected_filled_amount: None,
653 })
654}
655
656pub(crate) fn validate_order_support(order: &OrderAny) -> anyhow::Result<()> {
657 let order_type = order_type_to_derive(order.order_type()).map_err(|_| {
658 OrderDeniedReason::UnsupportedOrderType {
659 order_type: order.order_type(),
660 }
661 })?;
662
663 validate_order_flags(order, order_type)
664}
665
666pub(crate) fn validate_trigger_order_support(order: &OrderAny) -> anyhow::Result<()> {
667 let order_type = trigger_order_type_to_derive(order.order_type()).map_err(|_| {
668 OrderDeniedReason::UnsupportedOrderType {
669 order_type: order.order_type(),
670 }
671 })?;
672
673 validate_order_flags(order, order_type)?;
674 let trigger_type = order
675 .trigger_type()
676 .ok_or(OrderDeniedReason::MissingTriggerType)?;
677 trigger_price_type_to_derive(Some(trigger_type)).map_err(|e| {
678 OrderDeniedReason::ValidationFailed {
679 detail: e.to_string(),
680 }
681 })?;
682
683 Ok(())
684}
685
686fn validate_order_flags(order: &OrderAny, order_type: DeriveOrderType) -> anyhow::Result<()> {
687 if order.is_quote_quantity() {
688 anyhow::bail!(OrderDeniedReason::unsupported_quote_quantity());
689 }
690
691 if order.display_qty().is_some() {
692 anyhow::bail!(OrderDeniedReason::ValidationFailed {
693 detail: "Derive does not support display_qty iceberg instructions".to_string(),
694 });
695 }
696
697 let time_in_force = time_in_force_to_derive(order.time_in_force(), order.is_post_only())
698 .map_err(|_| OrderDeniedReason::UnsupportedTimeInForce(order.time_in_force()))?;
699 if order_type == DeriveOrderType::Market && order.is_post_only() {
700 anyhow::bail!(OrderDeniedReason::unsupported_post_only());
701 }
702
703 if order.is_reduce_only()
704 && order_type == DeriveOrderType::Limit
705 && !matches!(
706 time_in_force,
707 DeriveTimeInForce::Ioc | DeriveTimeInForce::Fok
708 )
709 {
710 anyhow::bail!(OrderDeniedReason::ValidationFailed {
711 detail: "reduce-only Derive limit orders require IOC or FOK time-in-force".to_string(),
712 });
713 }
714
715 Ok(())
716}
717
718fn resolve_limit_price(
719 order: &OrderAny,
720 explicit_price: Option<Decimal>,
721) -> anyhow::Result<Decimal> {
722 match explicit_price {
723 Some(p) => Ok(p),
724 None => match order.price() {
725 Some(p) => Ok(p.as_decimal()),
726 None => anyhow::bail!(
727 "missing limit price for order {} (market orders require an explicit slippage-adjusted price)",
728 order.client_order_id(),
729 ),
730 },
731 }
732}
733
734#[derive(Debug, Clone, Copy, PartialEq, Eq)]
735struct DeriveTriggerFields {
736 trigger_price: Decimal,
737 trigger_price_type: DeriveTriggerPriceType,
738 trigger_type: DeriveTriggerType,
739}
740
741#[expect(clippy::too_many_arguments)]
742fn build_signed_order_params(
743 order: &OrderAny,
744 instrument: &DeriveInstrument,
745 subaccount_id: u64,
746 wallet: Address,
747 signer: &PrivateKeySigner,
748 nonce: u64,
749 signature_expiry_sec: i64,
750 module_address: Address,
751 domain_separator: B256,
752 action_typehash: B256,
753 max_fee: Decimal,
754 limit_price: Decimal,
755 amount: Decimal,
756 order_type: DeriveOrderType,
757 time_in_force: DeriveTimeInForce,
758 trigger_fields: Option<DeriveTriggerFields>,
759) -> anyhow::Result<DeriveOrderParams> {
760 let direction = order_side_to_derive(order.order_side());
761
762 let asset_address: Address = instrument.base_asset_address.parse().with_context(|| {
763 format!(
764 "failed to parse base_asset_address `{}`",
765 instrument.base_asset_address.as_str(),
766 )
767 })?;
768
769 let sub_id =
770 U256::from_str_radix(instrument.base_asset_sub_id.as_str(), 10).with_context(|| {
771 format!(
772 "failed to parse base_asset_sub_id `{}`",
773 instrument.base_asset_sub_id.as_str(),
774 )
775 })?;
776
777 let trade = TradeModuleData {
778 asset_address,
779 sub_id,
780 limit_price,
781 amount,
782 max_fee,
783 recipient_id: subaccount_id,
784 is_bid: matches!(direction, DeriveOrderSide::Buy),
785 };
786
787 let ctx = ActionContext {
788 subaccount_id,
789 nonce,
790 module_address,
791 signature_expiry_sec,
792 owner: wallet,
793 signer: signer.address(),
794 };
795
796 let mut action = SignedAction::new(ctx, &trade, domain_separator, action_typehash);
797 action
798 .sign(signer)
799 .context("failed to sign Derive trade action")?;
800
801 Ok(DeriveOrderParams {
802 envelope: DeriveSignedEnvelope::from_signed_action(&action),
803 instrument_name: instrument.instrument_name,
804 direction,
805 order_type,
806 time_in_force,
807 limit_price,
808 amount,
809 max_fee,
810 label: order.client_order_id().to_string(),
811 referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
812 reduce_only: order.is_reduce_only().then_some(true),
813 mmp: order.is_post_only().then_some(false),
814 trigger_price: trigger_fields.map(|f| f.trigger_price),
815 trigger_price_type: trigger_fields.map(|f| f.trigger_price_type),
816 trigger_type: trigger_fields.map(|f| f.trigger_type),
817 })
818}
819
820const DERIVE_COLLECTION_MAX_PAGES: u32 = 1_000;
821const DERIVE_COLLECTION_MAX_RESTARTS: u32 = 2;
822
823pub(crate) struct PaginationCursor {
824 page: u32,
825 total: Option<(u32, i64)>,
826 ids: HashSet<String>,
827 snapshot_required: bool,
828 restarts: u32,
829}
830
831impl PaginationCursor {
832 pub(crate) fn new() -> Self {
833 Self {
834 page: 1,
835 total: None,
836 ids: HashSet::new(),
837 snapshot_required: true,
838 restarts: 0,
839 }
840 }
841
842 pub(crate) fn new_public() -> Self {
843 Self {
844 snapshot_required: false,
845 ..Self::new()
846 }
847 }
848
849 pub(crate) const fn page(&self) -> u32 {
850 self.page
851 }
852
853 pub(crate) fn is_complete(&self) -> bool {
854 self.total
855 .is_some_and(|(_, count)| usize::try_from(count) == Ok(self.ids.len()))
856 }
857
858 pub(crate) fn advance<'a>(
859 &mut self,
860 pagination: &DerivePaginationInfo,
861 ids: impl Iterator<Item = &'a str>,
862 ) -> HttpResult<bool> {
863 let total = self.validated_total(pagination)?;
864 let (pages, _) = total;
865 if self.total_changed(total) {
866 return Err(DeriveHttpError::Decode(
867 "Derive page count changed during collection".to_string(),
868 ));
869 }
870
871 let (horizon, _) = *self.total.get_or_insert(total);
872 let mut progress = false;
873 for id in ids {
874 progress |= self.ids.insert(id.to_owned());
875 }
876
877 if !progress && (pages > 1 || pagination.count > 0) {
878 if self.snapshot_required {
879 return Err(DeriveHttpError::Decode(
880 "Derive pagination made no progress".to_string(),
881 ));
882 }
883
884 return Ok(false);
885 }
886
887 if self.page >= horizon.min(pages) {
888 return Ok(false);
889 }
890
891 self.page += 1;
892 Ok(true)
893 }
894
895 pub(crate) fn restart_if_changed(
896 &mut self,
897 pagination: &DerivePaginationInfo,
898 ) -> HttpResult<bool> {
899 let total = self.validated_total(pagination)?;
900 if !self.total_changed(total) || self.restarts >= DERIVE_COLLECTION_MAX_RESTARTS {
901 return Ok(false);
902 }
903
904 let restarts = self.restarts + 1;
905
906 *self = Self {
907 restarts,
908 ..Self::new()
909 };
910
911 Ok(true)
912 }
913
914 fn total_changed(&self, total: (u32, i64)) -> bool {
915 self.snapshot_required && self.total.is_some_and(|previous| previous != total)
916 }
917
918 fn validated_total(&self, pagination: &DerivePaginationInfo) -> HttpResult<(u32, i64)> {
919 let pages = u32::try_from(pagination.num_pages)
920 .map_err(|_| DeriveHttpError::Decode("Invalid Derive page count".to_string()))?;
921
922 if pagination.count < 0 || (pages == 0 && pagination.count != 0) {
923 return Err(DeriveHttpError::Decode(
924 "Invalid Derive record count".to_string(),
925 ));
926 }
927
928 if (self.snapshot_required && pages > DERIVE_COLLECTION_MAX_PAGES)
929 || (self.page >= DERIVE_COLLECTION_MAX_PAGES && self.page < pages)
930 {
931 return Err(DeriveHttpError::Decode(
932 "Derive page count exceeds collection limit".to_string(),
933 ));
934 }
935
936 Ok((pages, pagination.count))
937 }
938}
939
940#[cfg(test)]
941mod tests {
942 use std::time::{SystemTime, UNIX_EPOCH};
943
944 use nautilus_core::{UUID4, UnixNanos};
945 use nautilus_model::{
946 enums::{OrderSide, OrderType, TimeInForce, TriggerType},
947 identifiers::{ClientOrderId, InstrumentId, StrategyId, Symbol, TraderId},
948 orders::{LimitOrder, MarketOrder, OrderTestBuilder},
949 types::{Price, Quantity},
950 };
951 use rstest::rstest;
952 use rust_decimal_macros::dec;
953 use serde_json::Value;
954
955 use super::*;
956 use crate::common::{consts::DERIVE_VENUE, enums::DeriveInstrumentType};
957
958 #[rstest]
959 #[case::empty(0, 0, None, false, 1)]
960 #[case::single(1, 1, Some("native-only"), false, 1)]
961 #[case::multiple(2, 2, Some("native-first"), true, 2)]
962 fn test_pagination_initial_page_boundary(
963 #[case] count: i64,
964 #[case] num_pages: i64,
965 #[case] id: Option<&str>,
966 #[case] more: bool,
967 #[case] next_page: u32,
968 ) {
969 let mut cursor = PaginationCursor::new();
970 let result = cursor
971 .advance(&DerivePaginationInfo { count, num_pages }, id.into_iter())
972 .unwrap();
973 assert_eq!(result, more);
974 assert_eq!(cursor.page(), next_page);
975 }
976
977 #[rstest]
978 fn test_public_pagination_accepts_long_history_with_bounded_progress() {
979 let mut cursor = PaginationCursor::new_public();
980
981 let result = cursor
982 .advance(
983 &DerivePaginationInfo {
984 count: 2_000,
985 num_pages: 2_000,
986 },
987 ["native-first"].into_iter(),
988 )
989 .unwrap();
990
991 assert!(result);
992 assert_eq!(cursor.page(), 2);
993 }
994
995 #[rstest]
996 #[case::public(false)]
997 #[case::private(true)]
998 fn test_pagination_bounds_collection_work(#[case] snapshot_required: bool) {
999 let mut cursor = if snapshot_required {
1000 PaginationCursor::new()
1001 } else {
1002 PaginationCursor::new_public()
1003 };
1004
1005 cursor.page = DERIVE_COLLECTION_MAX_PAGES;
1006
1007 let result = cursor.advance(
1008 &DerivePaginationInfo {
1009 count: 2_000,
1010 num_pages: 2_000,
1011 },
1012 ["native-next"].into_iter(),
1013 );
1014
1015 assert_eq!(
1016 result.unwrap_err().to_string(),
1017 "decode error: Derive page count exceeds collection limit",
1018 );
1019 assert_eq!(cursor.page(), DERIVE_COLLECTION_MAX_PAGES);
1020 }
1021
1022 fn canonical_wire<T: Serialize>(params: &T) -> String {
1023 let mut value = serde_json::to_value(params).unwrap();
1024 value.sort_all_objects();
1025 serde_json::to_string(&value).unwrap()
1026 }
1027
1028 fn to_value<T: Serialize>(value: T) -> Value {
1029 serde_json::to_value(value).unwrap()
1030 }
1031
1032 fn fixed_envelope(nonce: u64, signature: &str) -> DeriveSignedEnvelope {
1033 DeriveSignedEnvelope {
1034 subaccount_id: 30769,
1035 nonce,
1036 signer: "0xsigner".to_string(),
1037 signature_expiry_sec: 1_700_000_600 + (nonce as i64 - 123_456),
1038 signature: SecretString::from(signature),
1039 }
1040 }
1041
1042 #[rstest]
1043 #[case(0)]
1044 #[case(1_695_836_058_725_001_000)]
1045 #[case(u64::MAX)]
1046 fn test_nonce_string_round_trip(#[case] nonce: u64) {
1047 let mut envelope = fixed_envelope(123_456, "0xabc");
1048 envelope.nonce = nonce;
1049 let wire = serde_json::to_value(&envelope).unwrap();
1050 let decoded: DeriveSignedEnvelope = serde_json::from_value(wire.clone()).unwrap();
1051 assert_eq!(wire["nonce"], nonce.to_string());
1052 assert_eq!(decoded, envelope);
1053 }
1054
1055 #[rstest]
1056 #[case(Value::String(String::new()))]
1057 #[case(Value::String("-1".to_string()))]
1058 #[case(Value::String("18446744073709551616".to_string()))]
1059 #[case(Value::from(1_695_836_058_725_001_000_u64))]
1060 fn test_nonce_rejects_invalid_wire_values(#[case] nonce: Value) {
1061 let mut wire = serde_json::to_value(fixed_envelope(123_456, "0xabc")).unwrap();
1062 wire["nonce"] = nonce;
1063 assert!(serde_json::from_value::<DeriveSignedEnvelope>(wire).is_err());
1064 }
1065
1066 #[rstest]
1067 #[case("limit_buy_round_mainnet", false)]
1068 #[case("limit_buy_round_mainnet", true)]
1069 #[cfg_attr(feature = "high-precision", case("precision_boundary_mainnet", false))]
1070 #[cfg_attr(feature = "high-precision", case("precision_boundary_mainnet", true))]
1071 #[case("limit_sell_fractional_testnet", false)]
1072 #[case("limit_sell_fractional_testnet", true)]
1073 fn test_order_and_replace_wire_match_v3_oracle(#[case] case: &str, #[case] replace: bool) {
1074 let oracle: Value = serde_json::from_str(include_str!(
1075 "../../test_data/common/signing_trade_action_vectors.json"
1076 ))
1077 .unwrap();
1078 let vector = oracle["vectors"]
1079 .as_array()
1080 .unwrap()
1081 .iter()
1082 .find(|v| v["case"] == case)
1083 .unwrap();
1084 let trade = &vector["trade"];
1085 let price: Decimal = trade["limit_price"].as_str().unwrap().parse().unwrap();
1086 let amount: Decimal = trade["amount"].as_str().unwrap().parse().unwrap();
1087 let fee: Decimal = trade["max_fee"].as_str().unwrap().parse().unwrap();
1088
1089 let side = if trade["is_bid"].as_bool().unwrap() {
1090 OrderSide::Buy
1091 } else {
1092 OrderSide::Sell
1093 };
1094
1095 let order = build_test_limit_order(side, price, amount, false, false);
1096 let mut instrument = sample_perp_instrument();
1097 instrument.base_asset_address = trade["asset_address"].as_str().unwrap().into();
1098 instrument.base_asset_sub_id = trade["sub_id"].as_str().unwrap().into();
1099 let signer: PrivateKeySigner = vector["session_key"].as_str().unwrap().parse().unwrap();
1100 let wallet = vector["owner"].as_str().unwrap().parse().unwrap();
1101 let nonce = vector["nonce"].as_str().unwrap().parse().unwrap();
1102 let expiry = vector["signature_expiry_sec"].as_i64().unwrap();
1103 let module = vector["module_address"].as_str().unwrap().parse().unwrap();
1104 let domain = vector["domain_separator"]
1105 .as_str()
1106 .unwrap()
1107 .parse()
1108 .unwrap();
1109 let typehash = vector["action_typehash"].as_str().unwrap().parse().unwrap();
1110 let subaccount = vector["subaccount_id"].as_u64().unwrap();
1111
1112 let wire = if replace {
1113 serde_json::to_value(
1114 order_replace_to_derive_payload(
1115 &order,
1116 &instrument,
1117 subaccount,
1118 wallet,
1119 &signer,
1120 nonce,
1121 expiry,
1122 module,
1123 domain,
1124 typehash,
1125 fee,
1126 Some(amount),
1127 Some(price),
1128 "old-order",
1129 )
1130 .unwrap(),
1131 )
1132 .unwrap()
1133 } else {
1134 serde_json::to_value(
1135 order_to_derive_payload(
1136 &order,
1137 &instrument,
1138 subaccount,
1139 wallet,
1140 &signer,
1141 nonce,
1142 expiry,
1143 module,
1144 domain,
1145 typehash,
1146 fee,
1147 None,
1148 )
1149 .unwrap(),
1150 )
1151 .unwrap()
1152 };
1153
1154 let transmitted = TradeModuleData {
1155 asset_address: instrument.base_asset_address.as_str().parse().unwrap(),
1156 sub_id: U256::from_str_radix(instrument.base_asset_sub_id.as_str(), 10).unwrap(),
1157 limit_price: wire["limit_price"].as_str().unwrap().parse().unwrap(),
1158 amount: wire["amount"].as_str().unwrap().parse().unwrap(),
1159 max_fee: wire["max_fee"].as_str().unwrap().parse().unwrap(),
1160 recipient_id: wire["subaccount_id"].as_u64().unwrap(),
1161 is_bid: wire["direction"] == "buy",
1162 };
1163
1164 assert_ne!(wallet, signer.address());
1165 assert_eq!(
1166 format!(
1167 "0x{}",
1168 alloy_primitives::hex::encode(transmitted.encode().unwrap())
1169 ),
1170 vector["module_data"]
1171 );
1172 assert_eq!(wire["nonce"], vector["nonce"]);
1173 assert_eq!(
1174 wire["signer"].as_str().unwrap().to_ascii_lowercase(),
1175 vector["signer"].as_str().unwrap().to_ascii_lowercase()
1176 );
1177 assert_eq!(wire["signature_expiry_sec"], vector["signature_expiry_sec"]);
1178 assert_eq!(wire["signature"], vector["signature"]);
1179 }
1180
1181 #[rstest]
1182 #[case("limit_price", false)]
1183 #[case("amount", false)]
1184 #[case("max_fee", false)]
1185 #[case("limit_price", true)]
1186 #[case("amount", true)]
1187 #[case("max_fee", true)]
1188 fn test_order_and_replace_reject_excess_precision(#[case] field: &str, #[case] replace: bool) {
1189 let order = build_test_limit_order(OrderSide::Buy, dec!(3500), dec!(1), false, false);
1190 let instrument = sample_perp_instrument();
1191 let signer = sample_signer();
1192
1193 let price = if field == "limit_price" {
1194 dec!(3500.0000000000001)
1195 } else {
1196 dec!(3500)
1197 };
1198
1199 let amount = if field == "amount" {
1200 dec!(1.0000000000001)
1201 } else {
1202 dec!(1)
1203 };
1204
1205 let fee = if field == "max_fee" {
1206 dec!(0.5000000000001)
1207 } else {
1208 dec!(0.5)
1209 };
1210
1211 let error = if replace {
1212 order_replace_to_derive_payload(
1213 &order,
1214 &instrument,
1215 30769,
1216 sample_wallet(),
1217 &signer,
1218 1_700_000_000_000_000_000,
1219 fresh_expiry_secs(),
1220 sample_module(),
1221 sample_domain(),
1222 sample_typehash(),
1223 fee,
1224 Some(amount),
1225 Some(price),
1226 "old-order",
1227 )
1228 .unwrap_err()
1229 } else {
1230 build_signed_order_params(
1231 &order,
1232 &instrument,
1233 30769,
1234 sample_wallet(),
1235 &signer,
1236 1_700_000_000_000_000_000,
1237 fresh_expiry_secs(),
1238 sample_module(),
1239 sample_domain(),
1240 sample_typehash(),
1241 fee,
1242 price,
1243 amount,
1244 DeriveOrderType::Limit,
1245 DeriveTimeInForce::Gtc,
1246 None,
1247 )
1248 .unwrap_err()
1249 };
1250
1251 let message = format!("{error:#}");
1252 assert!(message.contains(field), "{message}");
1253 assert!(
1254 message.contains("financial input exceeds 12 fractional digits"),
1255 "{message}"
1256 );
1257 }
1258
1259 #[rstest]
1260 fn test_order_params_wire_round_trip_omits_unset_optionals() {
1261 let params = DeriveOrderParams {
1262 envelope: fixed_envelope(123_456, "0xabc"),
1263 instrument_name: "ETH-PERP".into(),
1264 direction: DeriveOrderSide::Buy,
1265 order_type: DeriveOrderType::Limit,
1266 time_in_force: DeriveTimeInForce::Gtc,
1267 limit_price: dec!(3500.01),
1268 amount: dec!(1.25),
1269 max_fee: dec!(0.5),
1270 label: "client-1".to_string(),
1271 referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
1272 reduce_only: None,
1273 mmp: None,
1274 trigger_price: None,
1275 trigger_price_type: None,
1276 trigger_type: None,
1277 };
1278
1279 let debug = format!("{params:?}");
1280
1281 let wire = canonical_wire(¶ms);
1282 let expected = include_str!("../../test_data/common/private_order_params_limit.json")
1283 .trim_end_matches('\n');
1284 let round_trip: DeriveOrderParams = serde_json::from_str(&wire).unwrap();
1285
1286 assert_eq!(wire, expected);
1287 assert_eq!(round_trip, params);
1288 assert!(debug.contains(REDACTED));
1289 assert!(!debug.contains("0xabc"));
1290 }
1291
1292 #[rstest]
1293 fn test_replace_params_wire_round_trip_includes_set_optionals() {
1294 let params = DeriveReplaceParams {
1295 order: DeriveOrderParams {
1296 envelope: fixed_envelope(123_457, "0xdef"),
1297 instrument_name: "ETH-PERP".into(),
1298 direction: DeriveOrderSide::Sell,
1299 order_type: DeriveOrderType::Limit,
1300 time_in_force: DeriveTimeInForce::PostOnly,
1301 limit_price: dec!(3499.5),
1302 amount: dec!(2),
1303 max_fee: dec!(0),
1304 label: "client-2".to_string(),
1305 referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
1306 reduce_only: Some(true),
1307 mmp: Some(false),
1308 trigger_price: None,
1309 trigger_price_type: None,
1310 trigger_type: None,
1311 },
1312 order_id_to_cancel: "ord-stale-1".to_string(),
1313 expected_filled_amount: None,
1314 };
1315
1316 let wire = canonical_wire(¶ms);
1317 let expected =
1318 include_str!("../../test_data/common/private_replace_params_reduce_mmp.json")
1319 .trim_end_matches('\n');
1320 let round_trip: DeriveReplaceParams = serde_json::from_str(&wire).unwrap();
1321
1322 assert_eq!(wire, expected);
1323 assert_eq!(round_trip, params);
1324 }
1325
1326 #[rstest]
1327 fn test_history_params_wire_round_trip_omits_unset_filters() {
1328 let params = DeriveGetOrderHistoryParams::new(30769, 2, 500);
1329
1330 let wire = canonical_wire(¶ms);
1331 let expected =
1332 include_str!("../../test_data/common/private_order_history_params_required.json")
1333 .trim_end_matches('\n');
1334 let round_trip: DeriveGetOrderHistoryParams = serde_json::from_str(&wire).unwrap();
1335
1336 assert_eq!(wire, expected);
1337 assert_eq!(round_trip, params);
1338 }
1339
1340 #[rstest]
1341 fn test_trigger_order_params_wire_round_trip_includes_trigger_fields() {
1342 let params = DeriveTriggerOrderParams {
1343 order: DeriveOrderParams {
1344 envelope: fixed_envelope(123_458, "0xfeed"),
1345 instrument_name: "ETH-PERP".into(),
1346 direction: DeriveOrderSide::Sell,
1347 order_type: DeriveOrderType::Market,
1348 time_in_force: DeriveTimeInForce::Gtc,
1349 limit_price: dec!(3400),
1350 amount: dec!(0.1),
1351 max_fee: dec!(0.5),
1352 label: "client-stop-1".to_string(),
1353 referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
1354 reduce_only: Some(true),
1355 mmp: None,
1356 trigger_price: Some(dec!(3450)),
1357 trigger_price_type: Some(DeriveTriggerPriceType::Mark),
1358 trigger_type: Some(DeriveTriggerType::Stoploss),
1359 },
1360 conn_id: "conn-1".to_string(),
1361 order_id: "trigger-order-1".to_string(),
1362 };
1363
1364 let wire = canonical_wire(¶ms);
1365 let expected =
1366 include_str!("../../test_data/common/private_trigger_order_params_stop_market.json")
1367 .trim_end_matches('\n');
1368 let round_trip: DeriveTriggerOrderParams = serde_json::from_str(&wire).unwrap();
1369
1370 assert_eq!(wire, expected);
1371 assert_eq!(round_trip.order, params.order);
1372 assert_eq!(round_trip.conn_id, "");
1373 assert_eq!(round_trip.order_id, "");
1374 }
1375
1376 fn sample_perp_instrument() -> DeriveInstrument {
1377 DeriveInstrument {
1380 amount_step: dec!(0.001),
1381 base_asset_address: "0x000000000000000000000000000000000000abcd".into(),
1382 base_asset_sub_id: "42".into(),
1383 base_currency: "ETH".into(),
1384 base_fee: dec!(0),
1385 instrument_name: "ETH-PERP".into(),
1386 instrument_type: DeriveInstrumentType::Perp,
1387 is_active: true,
1388 maker_fee_rate: dec!(0.0001),
1389 mark_price_fee_rate_cap: None,
1390 maximum_amount: dec!(1000),
1391 minimum_amount: dec!(0.001),
1392 option_details: None,
1393 perp_details: None,
1394 quote_currency: "USDC".into(),
1395 scheduled_activation: 0,
1396 scheduled_deactivation: 0,
1397 taker_fee_rate: dec!(0.0005),
1398 tick_size: dec!(0.01),
1399 raw: None,
1400 }
1401 }
1402
1403 fn sample_signer() -> PrivateKeySigner {
1404 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd"
1405 .parse()
1406 .unwrap()
1407 }
1408
1409 fn sample_wallet() -> Address {
1410 "0x000000000000000000000000000000000000aaaa"
1411 .parse()
1412 .unwrap()
1413 }
1414
1415 fn sample_module() -> Address {
1416 "0x000000000000000000000000000000000000bbbb"
1417 .parse()
1418 .unwrap()
1419 }
1420
1421 fn sample_domain() -> B256 {
1422 "0x2222222222222222222222222222222222222222222222222222222222222222"
1423 .parse()
1424 .unwrap()
1425 }
1426
1427 fn sample_typehash() -> B256 {
1428 "0x1111111111111111111111111111111111111111111111111111111111111111"
1429 .parse()
1430 .unwrap()
1431 }
1432
1433 fn fresh_expiry_secs() -> i64 {
1434 (SystemTime::now()
1435 .duration_since(UNIX_EPOCH)
1436 .unwrap()
1437 .as_secs() as i64)
1438 + 3600
1439 }
1440
1441 fn build_test_limit_order(
1442 side: OrderSide,
1443 price: Decimal,
1444 qty: Decimal,
1445 post_only: bool,
1446 reduce_only: bool,
1447 ) -> OrderAny {
1448 build_test_limit_order_with_time_in_force(
1449 side,
1450 price,
1451 qty,
1452 TimeInForce::Gtc,
1453 post_only,
1454 reduce_only,
1455 )
1456 }
1457
1458 fn build_test_limit_order_with_time_in_force(
1459 side: OrderSide,
1460 price: Decimal,
1461 qty: Decimal,
1462 time_in_force: TimeInForce,
1463 post_only: bool,
1464 reduce_only: bool,
1465 ) -> OrderAny {
1466 OrderAny::Limit(LimitOrder::new(
1467 TraderId::from("TRADER-001"),
1468 StrategyId::from("S-1"),
1469 InstrumentId::new(Symbol::new("ETH-PERP"), *DERIVE_VENUE),
1470 ClientOrderId::from("STRAT-PAYLOAD-1"),
1471 side,
1472 Quantity::from_decimal(qty).unwrap(),
1473 Price::from_decimal(price).unwrap(),
1474 time_in_force,
1475 None,
1476 post_only,
1477 reduce_only,
1478 false,
1479 None,
1480 None,
1481 None,
1482 None,
1483 None,
1484 None,
1485 None,
1486 None,
1487 None,
1488 None,
1489 None,
1490 UUID4::new(),
1491 UnixNanos::default(),
1492 ))
1493 }
1494
1495 fn build_test_stop_market_order() -> OrderAny {
1496 build_test_trigger_order(
1497 OrderType::StopMarket,
1498 OrderSide::Buy,
1499 None,
1500 TriggerType::Default,
1501 )
1502 }
1503
1504 fn build_test_trigger_order(
1505 order_type: OrderType,
1506 side: OrderSide,
1507 price: Option<Decimal>,
1508 trigger_type: TriggerType,
1509 ) -> OrderAny {
1510 let mut builder = OrderTestBuilder::new(order_type);
1511 builder
1512 .instrument_id(InstrumentId::new(Symbol::new("ETH-PERP"), *DERIVE_VENUE))
1513 .client_order_id(ClientOrderId::from("STRAT-PAYLOAD-STOP"))
1514 .side(side)
1515 .quantity(Quantity::from_decimal(dec!(1)).unwrap())
1516 .trigger_price(Price::from_decimal(dec!(3600)).unwrap())
1517 .trigger_type(trigger_type)
1518 .time_in_force(TimeInForce::Gtc);
1519
1520 if let Some(price) = price {
1521 builder.price(Price::from_decimal(price).unwrap());
1522 }
1523
1524 builder.build()
1525 }
1526
1527 fn build_test_market_order(side: OrderSide, qty: Decimal) -> OrderAny {
1528 OrderAny::Market(MarketOrder::new(
1529 TraderId::from("TRADER-001"),
1530 StrategyId::from("S-1"),
1531 InstrumentId::new(Symbol::new("ETH-PERP"), *DERIVE_VENUE),
1532 ClientOrderId::from("STRAT-PAYLOAD-MK"),
1533 side,
1534 Quantity::from_decimal(qty).unwrap(),
1535 TimeInForce::Ioc,
1536 UUID4::new(),
1537 UnixNanos::default(),
1538 false,
1539 false,
1540 None,
1541 None,
1542 None,
1543 None,
1544 None,
1545 None,
1546 None,
1547 None,
1548 ))
1549 }
1550
1551 #[rstest]
1552 #[case(OrderType::Limit)]
1553 #[case(OrderType::Market)]
1554 #[case(OrderType::StopMarket)]
1555 #[case(OrderType::StopLimit)]
1556 #[case(OrderType::MarketIfTouched)]
1557 #[case(OrderType::LimitIfTouched)]
1558 fn test_payload_quote_qty_rejection_and_exact_base_amount(
1559 #[case] order_type: OrderType,
1560 #[values(OrderSide::Buy, OrderSide::Sell)] side: OrderSide,
1561 #[values(false, true)] quote_qty: bool,
1562 ) {
1563 let mut order = OrderTestBuilder::new(order_type)
1564 .instrument_id(InstrumentId::from("ETH-PERP.DERIVE"))
1565 .side(side)
1566 .quantity(Quantity::from("1.237"))
1567 .price(Price::from("3500.00"))
1568 .trigger_price(Price::from("3500.00"))
1569 .trigger_type(TriggerType::MarkPrice)
1570 .time_in_force(TimeInForce::Ioc)
1571 .build();
1572 order.set_is_quote_quantity(quote_qty);
1573 let instrument = sample_perp_instrument();
1574 let signer = sample_signer();
1575
1576 let result = if matches!(order_type, OrderType::Limit | OrderType::Market) {
1577 order_to_derive_payload(
1578 &order,
1579 &instrument,
1580 30769,
1581 sample_wallet(),
1582 &signer,
1583 17_000_000_000_001,
1584 fresh_expiry_secs(),
1585 sample_module(),
1586 sample_domain(),
1587 sample_typehash(),
1588 dec!(1),
1589 Some(dec!(3500)),
1590 )
1591 } else {
1592 trigger_order_to_derive_payload(
1593 &order,
1594 &instrument,
1595 30769,
1596 sample_wallet(),
1597 &signer,
1598 17_000_000_000_001,
1599 fresh_expiry_secs(),
1600 sample_module(),
1601 sample_domain(),
1602 sample_typehash(),
1603 dec!(1),
1604 Some(dec!(3500)),
1605 "conn-1",
1606 "trigger-1",
1607 )
1608 .map(|payload| payload.order)
1609 };
1610
1611 if quote_qty {
1612 let error = result.unwrap_err();
1613 assert_eq!(error.to_string(), "UNSUPPORTED_QUOTE_QUANTITY");
1614 assert_eq!(
1615 error.downcast_ref::<OrderDeniedReason>(),
1616 Some(&OrderDeniedReason::unsupported_quote_quantity())
1617 );
1618 } else {
1619 assert_eq!(result.unwrap().amount, dec!(1.237));
1620 }
1621
1622 if matches!(order_type, OrderType::Limit | OrderType::Market) {
1623 for explicit_qty in [None, Some(dec!(0.419))] {
1624 let result = order_replace_to_derive_payload(
1625 &order,
1626 &instrument,
1627 30769,
1628 sample_wallet(),
1629 &signer,
1630 17_000_000_000_002,
1631 fresh_expiry_secs(),
1632 sample_module(),
1633 sample_domain(),
1634 sample_typehash(),
1635 dec!(1),
1636 explicit_qty,
1637 Some(dec!(3500)),
1638 "old-order",
1639 );
1640
1641 if quote_qty {
1642 assert_eq!(
1643 result.unwrap_err().to_string(),
1644 "UNSUPPORTED_QUOTE_QUANTITY"
1645 );
1646 } else {
1647 assert_eq!(
1648 result.unwrap().order.amount,
1649 explicit_qty.unwrap_or(dec!(1.237))
1650 );
1651 }
1652 }
1653 }
1654 }
1655
1656 #[rstest]
1657 fn test_order_to_derive_payload_limit_carries_all_required_fields() {
1658 let order = build_test_limit_order(OrderSide::Buy, dec!(3500), dec!(1), false, false);
1659 let instrument = sample_perp_instrument();
1660 let signer = sample_signer();
1661 let payload = order_to_derive_payload(
1662 &order,
1663 &instrument,
1664 30769,
1665 sample_wallet(),
1666 &signer,
1667 17_000_000_000_001,
1668 fresh_expiry_secs(),
1669 sample_module(),
1670 sample_domain(),
1671 sample_typehash(),
1672 dec!(1),
1673 None,
1674 )
1675 .map(to_value)
1676 .expect("payload built");
1677
1678 assert_eq!(payload["instrument_name"], "ETH-PERP");
1679 assert_eq!(payload["direction"], "buy");
1680 assert_eq!(payload["order_type"], "limit");
1681 assert_eq!(payload["time_in_force"], "gtc");
1682 assert_eq!(payload["label"], "STRAT-PAYLOAD-1");
1683 assert_eq!(payload["referral_code"], "nautilus");
1684 assert_eq!(payload["limit_price"], "3500");
1685 assert_eq!(payload["amount"], "1");
1686 assert_eq!(payload["max_fee"], "1");
1687 assert_eq!(payload["subaccount_id"], 30769);
1688 assert_eq!(payload["nonce"], "17000000000001");
1689 assert!(payload["signature_expiry_sec"].as_i64().unwrap() > 0);
1690 let signature = payload["signature"].as_str().unwrap();
1691 assert!(signature.starts_with("0x"));
1692 assert_eq!(signature.len(), 2 + 130, "65-byte sig = 132 hex chars");
1693 assert!(payload.get("reduce_only").is_none());
1694 assert!(payload.get("mmp").is_none());
1695 }
1696
1697 #[rstest]
1698 fn test_order_to_derive_payload_accepts_uint256_sub_id() {
1699 let order = build_test_limit_order(OrderSide::Buy, dec!(1), dec!(0.1), true, false);
1700 let mut instrument = sample_perp_instrument();
1701 instrument.instrument_name = "ETH-20260529-2200-C".into();
1702 instrument.base_asset_sub_id = "39614082202024973918552016768".into();
1703 instrument.instrument_type = DeriveInstrumentType::Option;
1704 let signer = sample_signer();
1705 let payload = order_to_derive_payload(
1706 &order,
1707 &instrument,
1708 30769,
1709 sample_wallet(),
1710 &signer,
1711 17_000_000_000_002,
1712 fresh_expiry_secs(),
1713 sample_module(),
1714 sample_domain(),
1715 sample_typehash(),
1716 dec!(1),
1717 None,
1718 )
1719 .map(to_value)
1720 .expect("payload built");
1721
1722 assert_eq!(payload["instrument_name"], "ETH-20260529-2200-C");
1723 assert_eq!(payload["time_in_force"], "post_only");
1724 assert!(payload["signature"].as_str().unwrap().starts_with("0x"));
1725 }
1726
1727 #[rstest]
1728 #[case(TimeInForce::Gtc, false, "gtc")]
1729 #[case(TimeInForce::Ioc, false, "ioc")]
1730 #[case(TimeInForce::Fok, false, "fok")]
1731 #[case(TimeInForce::Gtc, true, "post_only")]
1732 fn test_order_to_derive_payload_carries_supported_time_in_force(
1733 #[case] time_in_force: TimeInForce,
1734 #[case] post_only: bool,
1735 #[case] expected: &str,
1736 ) {
1737 let order = build_test_limit_order_with_time_in_force(
1738 OrderSide::Buy,
1739 dec!(3500),
1740 dec!(1),
1741 time_in_force,
1742 post_only,
1743 false,
1744 );
1745 let instrument = sample_perp_instrument();
1746 let signer = sample_signer();
1747 let payload = order_to_derive_payload(
1748 &order,
1749 &instrument,
1750 30769,
1751 sample_wallet(),
1752 &signer,
1753 17_000_000_000_002,
1754 fresh_expiry_secs(),
1755 sample_module(),
1756 sample_domain(),
1757 sample_typehash(),
1758 dec!(0),
1759 None,
1760 )
1761 .map(to_value)
1762 .expect("payload built");
1763
1764 assert_eq!(payload["time_in_force"], expected);
1765 }
1766
1767 #[rstest]
1768 #[case(TimeInForce::Ioc, false, true, "ioc", Some(true), None)]
1769 #[case(TimeInForce::Gtc, true, false, "post_only", None, Some(false))]
1770 fn test_order_to_derive_payload_emits_supported_reduce_only_and_mmp_flags(
1771 #[case] time_in_force: TimeInForce,
1772 #[case] post_only: bool,
1773 #[case] reduce_only: bool,
1774 #[case] expected_tif: &str,
1775 #[case] expected_reduce_only: Option<bool>,
1776 #[case] expected_mmp: Option<bool>,
1777 ) {
1778 let order = build_test_limit_order_with_time_in_force(
1779 OrderSide::Sell,
1780 dec!(3500),
1781 dec!(1),
1782 time_in_force,
1783 post_only,
1784 reduce_only,
1785 );
1786 let instrument = sample_perp_instrument();
1787 let signer = sample_signer();
1788 let payload = order_to_derive_payload(
1789 &order,
1790 &instrument,
1791 30769,
1792 sample_wallet(),
1793 &signer,
1794 17_000_000_000_002,
1795 fresh_expiry_secs(),
1796 sample_module(),
1797 sample_domain(),
1798 sample_typehash(),
1799 dec!(0),
1800 None,
1801 )
1802 .map(to_value)
1803 .expect("payload built");
1804
1805 assert_eq!(payload["direction"], "sell");
1806 assert_eq!(payload["time_in_force"], expected_tif);
1807 assert_eq!(
1808 payload["reduce_only"],
1809 serde_json::to_value(expected_reduce_only).unwrap()
1810 );
1811 assert_eq!(payload["mmp"], serde_json::to_value(expected_mmp).unwrap());
1812 }
1813
1814 #[rstest]
1815 #[case::limit(OrderType::Limit, false)]
1816 #[case::stop_market(OrderType::StopMarket, false)]
1817 #[case::stop_limit(OrderType::StopLimit, false)]
1818 #[case::limit_if_touched(OrderType::LimitIfTouched, false)]
1819 #[case::replace(OrderType::Limit, true)]
1820 fn test_payload_display_qty_rejection_and_ordinary_orders(
1821 #[case] order_type: OrderType,
1822 #[case] replace: bool,
1823 #[values(OrderSide::Buy, OrderSide::Sell)] side: OrderSide,
1824 #[values(None, Some("0.137"), Some("0.000"), Some("1.237"))] display_qty: Option<&str>,
1825 ) {
1826 let mut builder = OrderTestBuilder::new(order_type);
1827 builder
1828 .instrument_id(InstrumentId::from("ETH-PERP.DERIVE"))
1829 .side(side)
1830 .quantity(Quantity::from("1.237"))
1831 .price(Price::from("3500.00"))
1832 .trigger_price(Price::from("3500.00"))
1833 .trigger_type(TriggerType::MarkPrice)
1834 .time_in_force(TimeInForce::Gtc);
1835
1836 if let Some(qty) = display_qty {
1837 builder.display_qty(Quantity::from(qty));
1838 }
1839
1840 let order = builder.build();
1841 let mut instrument = sample_perp_instrument();
1842 if display_qty.is_some() {
1843 instrument.base_asset_address = "invalid-address".into();
1844 }
1845
1846 let explicit_price = (order_type == OrderType::StopMarket).then_some(dec!(3500));
1847 let signer = sample_signer();
1848
1849 let result = if replace {
1850 order_replace_to_derive_payload(
1851 &order,
1852 &instrument,
1853 30769,
1854 sample_wallet(),
1855 &signer,
1856 17_000_000_000_001,
1857 fresh_expiry_secs(),
1858 sample_module(),
1859 sample_domain(),
1860 sample_typehash(),
1861 dec!(1),
1862 Some(dec!(0.419)),
1863 Some(dec!(3500)),
1864 "old-order",
1865 )
1866 .map(|payload| payload.order)
1867 } else if order_type == OrderType::Limit {
1868 order_to_derive_payload(
1869 &order,
1870 &instrument,
1871 30769,
1872 sample_wallet(),
1873 &signer,
1874 17_000_000_000_001,
1875 fresh_expiry_secs(),
1876 sample_module(),
1877 sample_domain(),
1878 sample_typehash(),
1879 dec!(1),
1880 None,
1881 )
1882 } else {
1883 trigger_order_to_derive_payload(
1884 &order,
1885 &instrument,
1886 30769,
1887 sample_wallet(),
1888 &signer,
1889 17_000_000_000_001,
1890 fresh_expiry_secs(),
1891 sample_module(),
1892 sample_domain(),
1893 sample_typehash(),
1894 dec!(1),
1895 explicit_price,
1896 "conn-1",
1897 "trigger-1",
1898 )
1899 .map(|payload| payload.order)
1900 };
1901
1902 assert_eq!(order.display_qty(), display_qty.map(Quantity::from));
1903
1904 if display_qty.is_some() {
1905 assert_eq!(
1906 result.unwrap_err().to_string(),
1907 "VALIDATION_FAILED: Derive does not support display_qty iceberg instructions"
1908 );
1909 } else {
1910 let payload = result.unwrap();
1911 assert_eq!(
1912 payload.amount,
1913 if replace { dec!(0.419) } else { dec!(1.237) }
1914 );
1915 assert_eq!(payload.limit_price, dec!(3500));
1916 }
1917 }
1918
1919 #[rstest]
1920 fn test_order_to_derive_payload_market_uses_explicit_price_override() {
1921 let order = build_test_market_order(OrderSide::Buy, dec!(0.5));
1922 let instrument = sample_perp_instrument();
1923 let signer = sample_signer();
1924 let payload = order_to_derive_payload(
1925 &order,
1926 &instrument,
1927 30769,
1928 sample_wallet(),
1929 &signer,
1930 17_000_000_000_003,
1931 fresh_expiry_secs(),
1932 sample_module(),
1933 sample_domain(),
1934 sample_typehash(),
1935 dec!(0),
1936 Some(dec!(3519)),
1937 )
1938 .map(to_value)
1939 .expect("payload built");
1940
1941 assert_eq!(payload["order_type"], "market");
1942 assert_eq!(payload["limit_price"], "3519");
1943 }
1944
1945 #[rstest]
1946 fn test_order_to_derive_payload_market_without_explicit_price_errors() {
1947 let order = build_test_market_order(OrderSide::Buy, dec!(0.5));
1948 let instrument = sample_perp_instrument();
1949 let signer = sample_signer();
1950 let err = order_to_derive_payload(
1951 &order,
1952 &instrument,
1953 30769,
1954 sample_wallet(),
1955 &signer,
1956 17_000_000_000_004,
1957 fresh_expiry_secs(),
1958 sample_module(),
1959 sample_domain(),
1960 sample_typehash(),
1961 dec!(0),
1962 None,
1963 )
1964 .expect_err("market without price must error");
1965
1966 assert!(
1967 err.to_string().contains("missing limit price"),
1968 "unexpected error: {err}",
1969 );
1970 }
1971
1972 #[rstest]
1973 #[case(TimeInForce::Day, false, "UNSUPPORTED_TIME_IN_FORCE")]
1974 #[case(TimeInForce::Day, true, "UNSUPPORTED_TIME_IN_FORCE")]
1975 #[case(TimeInForce::Ioc, true, "UNSUPPORTED_TIME_IN_FORCE")]
1976 #[case(TimeInForce::Fok, true, "UNSUPPORTED_TIME_IN_FORCE")]
1977 fn test_order_to_derive_payload_rejects_unsupported_tif(
1978 #[case] time_in_force: TimeInForce,
1979 #[case] post_only: bool,
1980 #[case] reason_fragment: &str,
1981 ) {
1982 let order = build_test_limit_order_with_time_in_force(
1983 OrderSide::Buy,
1984 dec!(3500),
1985 dec!(1),
1986 time_in_force,
1987 post_only,
1988 false,
1989 );
1990 let instrument = sample_perp_instrument();
1991 let signer = sample_signer();
1992 let err = order_to_derive_payload(
1993 &order,
1994 &instrument,
1995 30769,
1996 sample_wallet(),
1997 &signer,
1998 17_000_000_000_005,
1999 fresh_expiry_secs(),
2000 sample_module(),
2001 sample_domain(),
2002 sample_typehash(),
2003 dec!(0),
2004 None,
2005 )
2006 .expect_err("unsupported TIF must error");
2007
2008 assert!(
2009 err.to_string().contains(reason_fragment),
2010 "unexpected error: {err}",
2011 );
2012 }
2013
2014 #[rstest]
2015 fn test_order_to_derive_payload_rejects_stop_order_before_price_resolution() {
2016 let order = build_test_stop_market_order();
2017 let instrument = sample_perp_instrument();
2018 let signer = sample_signer();
2019 let err = order_to_derive_payload(
2020 &order,
2021 &instrument,
2022 30769,
2023 sample_wallet(),
2024 &signer,
2025 17_000_000_000_006,
2026 fresh_expiry_secs(),
2027 sample_module(),
2028 sample_domain(),
2029 sample_typehash(),
2030 dec!(0),
2031 None,
2032 )
2033 .expect_err("unsupported order type must error");
2034
2035 assert!(
2036 err.to_string().contains("UNSUPPORTED_ORDER_TYPE"),
2037 "unexpected error: {err}",
2038 );
2039 assert!(
2040 !err.to_string().contains("missing limit price"),
2041 "unexpected error: {err}",
2042 );
2043 }
2044
2045 #[rstest]
2046 fn test_trigger_order_to_derive_payload_stop_market_uses_mark_trigger() {
2047 let order = build_test_trigger_order(
2048 OrderType::StopMarket,
2049 OrderSide::Sell,
2050 None,
2051 TriggerType::MarkPrice,
2052 );
2053 let instrument = sample_perp_instrument();
2054 let signer = sample_signer();
2055 let payload = trigger_order_to_derive_payload(
2056 &order,
2057 &instrument,
2058 30769,
2059 sample_wallet(),
2060 &signer,
2061 17_000_000_000_007,
2062 fresh_expiry_secs(),
2063 sample_module(),
2064 sample_domain(),
2065 sample_typehash(),
2066 dec!(1),
2067 Some(dec!(3400)),
2068 "conn-1",
2069 "trigger-1",
2070 )
2071 .map(to_value)
2072 .expect("trigger payload built");
2073
2074 assert_eq!(payload.get("conn_id"), None);
2075 assert_eq!(payload.get("order_id"), None);
2076 assert_eq!(payload["direction"], "sell");
2077 assert_eq!(payload["order_type"], "market");
2078 assert_eq!(payload["limit_price"], "3400");
2079 assert_eq!(payload["trigger_price"], "3600");
2080 assert_eq!(payload["trigger_price_type"], "mark");
2081 assert_eq!(payload["trigger_type"], "stoploss");
2082 }
2083
2084 #[rstest]
2085 fn test_trigger_order_to_derive_payload_stop_limit_maps_limit_stoploss() {
2086 let order = build_test_trigger_order(
2087 OrderType::StopLimit,
2088 OrderSide::Sell,
2089 Some(dec!(3500)),
2090 TriggerType::MarkPrice,
2091 );
2092 let instrument = sample_perp_instrument();
2093 let signer = sample_signer();
2094 let payload = trigger_order_to_derive_payload(
2095 &order,
2096 &instrument,
2097 30769,
2098 sample_wallet(),
2099 &signer,
2100 17_000_000_000_008,
2101 fresh_expiry_secs(),
2102 sample_module(),
2103 sample_domain(),
2104 sample_typehash(),
2105 dec!(1),
2106 None,
2107 "conn-1",
2108 "trigger-2",
2109 )
2110 .map(to_value)
2111 .expect("trigger payload built");
2112
2113 assert_eq!(payload["order_type"], "limit");
2114 assert_eq!(payload["limit_price"], "3500");
2115 assert_eq!(payload["trigger_type"], "stoploss");
2116 }
2117
2118 #[rstest]
2119 #[case(
2120 OrderType::MarketIfTouched,
2121 DeriveOrderType::Market,
2122 DeriveTriggerType::Takeprofit
2123 )]
2124 #[case(
2125 OrderType::LimitIfTouched,
2126 DeriveOrderType::Limit,
2127 DeriveTriggerType::Takeprofit
2128 )]
2129 fn test_trigger_order_to_derive_payload_take_profit_types(
2130 #[case] order_type: OrderType,
2131 #[case] expected_order_type: DeriveOrderType,
2132 #[case] expected_trigger_type: DeriveTriggerType,
2133 ) {
2134 let price = if order_type == OrderType::LimitIfTouched {
2135 Some(dec!(3700))
2136 } else {
2137 None
2138 };
2139
2140 let explicit_price = if price.is_none() {
2141 Some(dec!(3705))
2142 } else {
2143 None
2144 };
2145
2146 let order =
2147 build_test_trigger_order(order_type, OrderSide::Buy, price, TriggerType::MarkPrice);
2148 let instrument = sample_perp_instrument();
2149 let signer = sample_signer();
2150 let payload = trigger_order_to_derive_payload(
2151 &order,
2152 &instrument,
2153 30769,
2154 sample_wallet(),
2155 &signer,
2156 17_000_000_000_009,
2157 fresh_expiry_secs(),
2158 sample_module(),
2159 sample_domain(),
2160 sample_typehash(),
2161 dec!(1),
2162 explicit_price,
2163 "conn-1",
2164 "trigger-3",
2165 )
2166 .expect("trigger payload built");
2167
2168 assert_eq!(payload.order.order_type, expected_order_type);
2169 assert_eq!(payload.order.trigger_type, Some(expected_trigger_type));
2170 }
2171
2172 #[rstest]
2173 fn test_trigger_order_to_derive_payload_rejects_index_trigger_price_type() {
2174 let order = build_test_trigger_order(
2175 OrderType::StopMarket,
2176 OrderSide::Buy,
2177 None,
2178 TriggerType::IndexPrice,
2179 );
2180 let instrument = sample_perp_instrument();
2181 let signer = sample_signer();
2182 let err = trigger_order_to_derive_payload(
2183 &order,
2184 &instrument,
2185 30769,
2186 sample_wallet(),
2187 &signer,
2188 17_000_000_000_010,
2189 fresh_expiry_secs(),
2190 sample_module(),
2191 sample_domain(),
2192 sample_typehash(),
2193 dec!(1),
2194 Some(dec!(3618)),
2195 "conn-1",
2196 "trigger-4",
2197 )
2198 .expect_err("index trigger price type must fail");
2199
2200 assert!(
2201 err.to_string()
2202 .contains("Derive currently accepts only MarkPrice"),
2203 "unexpected error: {err}",
2204 );
2205 }
2206
2207 #[rstest]
2208 fn test_trigger_order_to_derive_payload_maps_default_trigger_type_to_mark() {
2209 let order = build_test_trigger_order(
2210 OrderType::StopMarket,
2211 OrderSide::Buy,
2212 None,
2213 TriggerType::Default,
2214 );
2215 let instrument = sample_perp_instrument();
2216 let signer = sample_signer();
2217 let payload = trigger_order_to_derive_payload(
2218 &order,
2219 &instrument,
2220 30769,
2221 sample_wallet(),
2222 &signer,
2223 17_000_000_000_011,
2224 fresh_expiry_secs(),
2225 sample_module(),
2226 sample_domain(),
2227 sample_typehash(),
2228 dec!(1),
2229 Some(dec!(3618)),
2230 "conn-1",
2231 "trigger-5",
2232 )
2233 .expect("default trigger type should map to mark");
2234
2235 assert_eq!(
2236 payload.order.trigger_price_type,
2237 Some(DeriveTriggerPriceType::Mark),
2238 );
2239 }
2240
2241 #[rstest]
2242 fn test_order_replace_to_derive_payload_stamps_cancel_clause_and_overrides() {
2243 let order = build_test_limit_order(OrderSide::Buy, dec!(3500), dec!(1), false, false);
2244 let instrument = sample_perp_instrument();
2245 let signer = sample_signer();
2246 let payload = order_replace_to_derive_payload(
2247 &order,
2248 &instrument,
2249 30769,
2250 sample_wallet(),
2251 &signer,
2252 17_000_000_000_010,
2253 fresh_expiry_secs(),
2254 sample_module(),
2255 sample_domain(),
2256 sample_typehash(),
2257 dec!(1),
2258 Some(dec!(2)),
2259 Some(dec!(3505)),
2260 "ord-stale-1",
2261 )
2262 .map(to_value)
2263 .expect("replace payload built");
2264
2265 assert_eq!(payload["order_id_to_cancel"], "ord-stale-1");
2266 assert_eq!(payload["amount"], "2");
2267 assert_eq!(payload["limit_price"], "3505");
2268 assert_eq!(payload["direction"], "buy");
2269 assert_eq!(payload["order_type"], "limit");
2270 assert_eq!(payload["time_in_force"], "gtc");
2271 assert_eq!(payload["label"], "STRAT-PAYLOAD-1");
2272 assert_eq!(payload["subaccount_id"], 30769);
2273 assert_eq!(payload["nonce"], "17000000000010");
2274 let signature = payload["signature"].as_str().unwrap();
2275 assert!(signature.starts_with("0x"));
2276 assert_eq!(signature.len(), 2 + 130);
2277 }
2278
2279 #[rstest]
2280 fn test_order_replace_to_derive_payload_falls_back_to_cached_quantity_and_price() {
2281 let order = build_test_limit_order(OrderSide::Sell, dec!(3501), dec!(0.5), false, false);
2282 let instrument = sample_perp_instrument();
2283 let signer = sample_signer();
2284 let payload = order_replace_to_derive_payload(
2285 &order,
2286 &instrument,
2287 30769,
2288 sample_wallet(),
2289 &signer,
2290 17_000_000_000_011,
2291 fresh_expiry_secs(),
2292 sample_module(),
2293 sample_domain(),
2294 sample_typehash(),
2295 dec!(0),
2296 None,
2297 None,
2298 "ord-stale-2",
2299 )
2300 .map(to_value)
2301 .expect("replace payload built");
2302
2303 assert_eq!(payload["order_id_to_cancel"], "ord-stale-2");
2304 assert_eq!(payload["amount"], "0.5");
2305 assert_eq!(payload["limit_price"], "3501");
2306 assert_eq!(payload["direction"], "sell");
2307 }
2308
2309 #[rstest]
2310 fn test_order_replace_to_derive_payload_market_without_explicit_price_errors() {
2311 let order = build_test_market_order(OrderSide::Buy, dec!(0.5));
2312 let instrument = sample_perp_instrument();
2313 let signer = sample_signer();
2314 let err = order_replace_to_derive_payload(
2315 &order,
2316 &instrument,
2317 30769,
2318 sample_wallet(),
2319 &signer,
2320 17_000_000_000_012,
2321 fresh_expiry_secs(),
2322 sample_module(),
2323 sample_domain(),
2324 sample_typehash(),
2325 dec!(0),
2326 None,
2327 None,
2328 "ord-stale-3",
2329 )
2330 .expect_err("market replace without price must error");
2331
2332 assert!(
2333 err.to_string().contains("missing limit price"),
2334 "unexpected error: {err}",
2335 );
2336 }
2337
2338 #[rstest]
2339 fn test_order_replace_to_derive_payload_rejects_stop_order_before_price_resolution() {
2340 let order = build_test_stop_market_order();
2341 let instrument = sample_perp_instrument();
2342 let signer = sample_signer();
2343 let err = order_replace_to_derive_payload(
2344 &order,
2345 &instrument,
2346 30769,
2347 sample_wallet(),
2348 &signer,
2349 17_000_000_000_013,
2350 fresh_expiry_secs(),
2351 sample_module(),
2352 sample_domain(),
2353 sample_typehash(),
2354 dec!(0),
2355 None,
2356 None,
2357 "ord-stale-4",
2358 )
2359 .expect_err("unsupported order type must error");
2360
2361 assert!(
2362 err.to_string().contains("UNSUPPORTED_ORDER_TYPE"),
2363 "unexpected error: {err}",
2364 );
2365 assert!(
2366 !err.to_string().contains("missing limit price"),
2367 "unexpected error: {err}",
2368 );
2369 }
2370}