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) -> Result<(), OrderDeniedReason> {
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) -> Result<(), OrderDeniedReason> {
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(
687 order: &OrderAny,
688 order_type: DeriveOrderType,
689) -> Result<(), OrderDeniedReason> {
690 let time_in_force = time_in_force_to_derive(order.time_in_force(), order.is_post_only())
691 .map_err(|_| OrderDeniedReason::UnsupportedTimeInForce(order.time_in_force()))?;
692 if order_type == DeriveOrderType::Market && order.is_post_only() {
693 return Err(OrderDeniedReason::ValidationFailed {
694 detail: "post-only is not supported for Derive market orders".to_string(),
695 });
696 }
697
698 if order.is_reduce_only()
699 && order_type == DeriveOrderType::Limit
700 && !matches!(
701 time_in_force,
702 DeriveTimeInForce::Ioc | DeriveTimeInForce::Fok
703 )
704 {
705 return Err(OrderDeniedReason::ValidationFailed {
706 detail: "reduce-only Derive limit orders require IOC or FOK time-in-force".to_string(),
707 });
708 }
709
710 Ok(())
711}
712
713fn resolve_limit_price(
714 order: &OrderAny,
715 explicit_price: Option<Decimal>,
716) -> anyhow::Result<Decimal> {
717 match explicit_price {
718 Some(p) => Ok(p),
719 None => match order.price() {
720 Some(p) => Ok(p.as_decimal()),
721 None => anyhow::bail!(
722 "missing limit price for order {} (market orders require an explicit slippage-adjusted price)",
723 order.client_order_id(),
724 ),
725 },
726 }
727}
728
729#[derive(Debug, Clone, Copy, PartialEq, Eq)]
730struct DeriveTriggerFields {
731 trigger_price: Decimal,
732 trigger_price_type: DeriveTriggerPriceType,
733 trigger_type: DeriveTriggerType,
734}
735
736#[expect(clippy::too_many_arguments)]
737fn build_signed_order_params(
738 order: &OrderAny,
739 instrument: &DeriveInstrument,
740 subaccount_id: u64,
741 wallet: Address,
742 signer: &PrivateKeySigner,
743 nonce: u64,
744 signature_expiry_sec: i64,
745 module_address: Address,
746 domain_separator: B256,
747 action_typehash: B256,
748 max_fee: Decimal,
749 limit_price: Decimal,
750 amount: Decimal,
751 order_type: DeriveOrderType,
752 time_in_force: DeriveTimeInForce,
753 trigger_fields: Option<DeriveTriggerFields>,
754) -> anyhow::Result<DeriveOrderParams> {
755 let direction = order_side_to_derive(order.order_side());
756
757 let asset_address: Address = instrument.base_asset_address.parse().with_context(|| {
758 format!(
759 "failed to parse base_asset_address `{}`",
760 instrument.base_asset_address.as_str(),
761 )
762 })?;
763
764 let sub_id =
765 U256::from_str_radix(instrument.base_asset_sub_id.as_str(), 10).with_context(|| {
766 format!(
767 "failed to parse base_asset_sub_id `{}`",
768 instrument.base_asset_sub_id.as_str(),
769 )
770 })?;
771
772 let trade = TradeModuleData {
773 asset_address,
774 sub_id,
775 limit_price,
776 amount,
777 max_fee,
778 recipient_id: subaccount_id,
779 is_bid: matches!(direction, DeriveOrderSide::Buy),
780 };
781
782 let ctx = ActionContext {
783 subaccount_id,
784 nonce,
785 module_address,
786 signature_expiry_sec,
787 owner: wallet,
788 signer: signer.address(),
789 };
790
791 let mut action = SignedAction::new(ctx, &trade, domain_separator, action_typehash);
792 action
793 .sign(signer)
794 .context("failed to sign Derive trade action")?;
795
796 Ok(DeriveOrderParams {
797 envelope: DeriveSignedEnvelope::from_signed_action(&action),
798 instrument_name: instrument.instrument_name,
799 direction,
800 order_type,
801 time_in_force,
802 limit_price,
803 amount,
804 max_fee,
805 label: order.client_order_id().to_string(),
806 referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
807 reduce_only: order.is_reduce_only().then_some(true),
808 mmp: order.is_post_only().then_some(false),
809 trigger_price: trigger_fields.map(|f| f.trigger_price),
810 trigger_price_type: trigger_fields.map(|f| f.trigger_price_type),
811 trigger_type: trigger_fields.map(|f| f.trigger_type),
812 })
813}
814
815const DERIVE_COLLECTION_MAX_PAGES: u32 = 1_000;
816const DERIVE_COLLECTION_MAX_RESTARTS: u32 = 2;
817
818pub(crate) struct PaginationCursor {
819 page: u32,
820 total: Option<(u32, i64)>,
821 ids: HashSet<String>,
822 snapshot_required: bool,
823 restarts: u32,
824}
825
826impl PaginationCursor {
827 pub(crate) fn new() -> Self {
828 Self {
829 page: 1,
830 total: None,
831 ids: HashSet::new(),
832 snapshot_required: true,
833 restarts: 0,
834 }
835 }
836
837 pub(crate) fn new_public() -> Self {
838 Self {
839 snapshot_required: false,
840 ..Self::new()
841 }
842 }
843
844 pub(crate) const fn page(&self) -> u32 {
845 self.page
846 }
847
848 pub(crate) fn is_complete(&self) -> bool {
849 self.total
850 .is_some_and(|(_, count)| usize::try_from(count) == Ok(self.ids.len()))
851 }
852
853 pub(crate) fn advance<'a>(
854 &mut self,
855 pagination: &DerivePaginationInfo,
856 ids: impl Iterator<Item = &'a str>,
857 ) -> HttpResult<bool> {
858 let total = self.validated_total(pagination)?;
859 let (pages, _) = total;
860 if self.total_changed(total) {
861 return Err(DeriveHttpError::Decode(
862 "Derive page count changed during collection".to_string(),
863 ));
864 }
865
866 let (horizon, _) = *self.total.get_or_insert(total);
867 let mut progress = false;
868 for id in ids {
869 progress |= self.ids.insert(id.to_owned());
870 }
871
872 if !progress && (pages > 1 || pagination.count > 0) {
873 if self.snapshot_required {
874 return Err(DeriveHttpError::Decode(
875 "Derive pagination made no progress".to_string(),
876 ));
877 }
878
879 return Ok(false);
880 }
881
882 if self.page >= horizon.min(pages) {
883 return Ok(false);
884 }
885
886 self.page += 1;
887 Ok(true)
888 }
889
890 pub(crate) fn restart_if_changed(
891 &mut self,
892 pagination: &DerivePaginationInfo,
893 ) -> HttpResult<bool> {
894 let total = self.validated_total(pagination)?;
895 if !self.total_changed(total) || self.restarts >= DERIVE_COLLECTION_MAX_RESTARTS {
896 return Ok(false);
897 }
898
899 let restarts = self.restarts + 1;
900
901 *self = Self {
902 restarts,
903 ..Self::new()
904 };
905
906 Ok(true)
907 }
908
909 fn total_changed(&self, total: (u32, i64)) -> bool {
910 self.snapshot_required && self.total.is_some_and(|previous| previous != total)
911 }
912
913 fn validated_total(&self, pagination: &DerivePaginationInfo) -> HttpResult<(u32, i64)> {
914 let pages = u32::try_from(pagination.num_pages)
915 .map_err(|_| DeriveHttpError::Decode("Invalid Derive page count".to_string()))?;
916
917 if pagination.count < 0 || (pages == 0 && pagination.count != 0) {
918 return Err(DeriveHttpError::Decode(
919 "Invalid Derive record count".to_string(),
920 ));
921 }
922
923 if (self.snapshot_required && pages > DERIVE_COLLECTION_MAX_PAGES)
924 || (self.page >= DERIVE_COLLECTION_MAX_PAGES && self.page < pages)
925 {
926 return Err(DeriveHttpError::Decode(
927 "Derive page count exceeds collection limit".to_string(),
928 ));
929 }
930
931 Ok((pages, pagination.count))
932 }
933}
934
935#[cfg(test)]
936mod tests {
937 use std::time::{SystemTime, UNIX_EPOCH};
938
939 use nautilus_core::{UUID4, UnixNanos};
940 use nautilus_model::{
941 enums::{OrderSide, OrderType, TimeInForce, TriggerType},
942 identifiers::{ClientOrderId, InstrumentId, StrategyId, Symbol, TraderId},
943 orders::{LimitOrder, MarketOrder, OrderTestBuilder},
944 types::{Price, Quantity},
945 };
946 use rstest::rstest;
947 use rust_decimal_macros::dec;
948 use serde_json::Value;
949
950 use super::*;
951 use crate::common::{consts::DERIVE_VENUE, enums::DeriveInstrumentType};
952
953 #[rstest]
954 #[case::empty(0, 0, None, false, 1)]
955 #[case::single(1, 1, Some("native-only"), false, 1)]
956 #[case::multiple(2, 2, Some("native-first"), true, 2)]
957 fn test_pagination_initial_page_boundary(
958 #[case] count: i64,
959 #[case] num_pages: i64,
960 #[case] id: Option<&str>,
961 #[case] more: bool,
962 #[case] next_page: u32,
963 ) {
964 let mut cursor = PaginationCursor::new();
965 let result = cursor
966 .advance(&DerivePaginationInfo { count, num_pages }, id.into_iter())
967 .unwrap();
968 assert_eq!(result, more);
969 assert_eq!(cursor.page(), next_page);
970 }
971
972 #[rstest]
973 fn test_public_pagination_accepts_long_history_with_bounded_progress() {
974 let mut cursor = PaginationCursor::new_public();
975
976 let result = cursor
977 .advance(
978 &DerivePaginationInfo {
979 count: 2_000,
980 num_pages: 2_000,
981 },
982 ["native-first"].into_iter(),
983 )
984 .unwrap();
985
986 assert!(result);
987 assert_eq!(cursor.page(), 2);
988 }
989
990 #[rstest]
991 #[case::public(false)]
992 #[case::private(true)]
993 fn test_pagination_bounds_collection_work(#[case] snapshot_required: bool) {
994 let mut cursor = if snapshot_required {
995 PaginationCursor::new()
996 } else {
997 PaginationCursor::new_public()
998 };
999
1000 cursor.page = DERIVE_COLLECTION_MAX_PAGES;
1001
1002 let result = cursor.advance(
1003 &DerivePaginationInfo {
1004 count: 2_000,
1005 num_pages: 2_000,
1006 },
1007 ["native-next"].into_iter(),
1008 );
1009
1010 assert_eq!(
1011 result.unwrap_err().to_string(),
1012 "decode error: Derive page count exceeds collection limit",
1013 );
1014 assert_eq!(cursor.page(), DERIVE_COLLECTION_MAX_PAGES);
1015 }
1016
1017 fn canonical_wire<T: Serialize>(params: &T) -> String {
1018 let mut value = serde_json::to_value(params).unwrap();
1019 value.sort_all_objects();
1020 serde_json::to_string(&value).unwrap()
1021 }
1022
1023 fn to_value<T: Serialize>(value: T) -> Value {
1024 serde_json::to_value(value).unwrap()
1025 }
1026
1027 fn fixed_envelope(nonce: u64, signature: &str) -> DeriveSignedEnvelope {
1028 DeriveSignedEnvelope {
1029 subaccount_id: 30769,
1030 nonce,
1031 signer: "0xsigner".to_string(),
1032 signature_expiry_sec: 1_700_000_600 + (nonce as i64 - 123_456),
1033 signature: SecretString::from(signature),
1034 }
1035 }
1036
1037 #[rstest]
1038 #[case(0)]
1039 #[case(1_695_836_058_725_001_000)]
1040 #[case(u64::MAX)]
1041 fn test_nonce_string_round_trip(#[case] nonce: u64) {
1042 let mut envelope = fixed_envelope(123_456, "0xabc");
1043 envelope.nonce = nonce;
1044 let wire = serde_json::to_value(&envelope).unwrap();
1045 let decoded: DeriveSignedEnvelope = serde_json::from_value(wire.clone()).unwrap();
1046 assert_eq!(wire["nonce"], nonce.to_string());
1047 assert_eq!(decoded, envelope);
1048 }
1049
1050 #[rstest]
1051 #[case(Value::String(String::new()))]
1052 #[case(Value::String("-1".to_string()))]
1053 #[case(Value::String("18446744073709551616".to_string()))]
1054 #[case(Value::from(1_695_836_058_725_001_000_u64))]
1055 fn test_nonce_rejects_invalid_wire_values(#[case] nonce: Value) {
1056 let mut wire = serde_json::to_value(fixed_envelope(123_456, "0xabc")).unwrap();
1057 wire["nonce"] = nonce;
1058 assert!(serde_json::from_value::<DeriveSignedEnvelope>(wire).is_err());
1059 }
1060
1061 #[rstest]
1062 #[case("limit_buy_round_mainnet", false)]
1063 #[case("limit_buy_round_mainnet", true)]
1064 #[cfg_attr(feature = "high-precision", case("precision_boundary_mainnet", false))]
1065 #[cfg_attr(feature = "high-precision", case("precision_boundary_mainnet", true))]
1066 #[case("limit_sell_fractional_testnet", false)]
1067 #[case("limit_sell_fractional_testnet", true)]
1068 fn test_order_and_replace_wire_match_v3_oracle(#[case] case: &str, #[case] replace: bool) {
1069 let oracle: Value = serde_json::from_str(include_str!(
1070 "../../test_data/common/signing_trade_action_vectors.json"
1071 ))
1072 .unwrap();
1073 let vector = oracle["vectors"]
1074 .as_array()
1075 .unwrap()
1076 .iter()
1077 .find(|v| v["case"] == case)
1078 .unwrap();
1079 let trade = &vector["trade"];
1080 let price: Decimal = trade["limit_price"].as_str().unwrap().parse().unwrap();
1081 let amount: Decimal = trade["amount"].as_str().unwrap().parse().unwrap();
1082 let fee: Decimal = trade["max_fee"].as_str().unwrap().parse().unwrap();
1083
1084 let side = if trade["is_bid"].as_bool().unwrap() {
1085 OrderSide::Buy
1086 } else {
1087 OrderSide::Sell
1088 };
1089
1090 let order = build_test_limit_order(side, price, amount, false, false);
1091 let mut instrument = sample_perp_instrument();
1092 instrument.base_asset_address = trade["asset_address"].as_str().unwrap().into();
1093 instrument.base_asset_sub_id = trade["sub_id"].as_str().unwrap().into();
1094 let signer: PrivateKeySigner = vector["session_key"].as_str().unwrap().parse().unwrap();
1095 let wallet = vector["owner"].as_str().unwrap().parse().unwrap();
1096 let nonce = vector["nonce"].as_str().unwrap().parse().unwrap();
1097 let expiry = vector["signature_expiry_sec"].as_i64().unwrap();
1098 let module = vector["module_address"].as_str().unwrap().parse().unwrap();
1099 let domain = vector["domain_separator"]
1100 .as_str()
1101 .unwrap()
1102 .parse()
1103 .unwrap();
1104 let typehash = vector["action_typehash"].as_str().unwrap().parse().unwrap();
1105 let subaccount = vector["subaccount_id"].as_u64().unwrap();
1106
1107 let wire = if replace {
1108 serde_json::to_value(
1109 order_replace_to_derive_payload(
1110 &order,
1111 &instrument,
1112 subaccount,
1113 wallet,
1114 &signer,
1115 nonce,
1116 expiry,
1117 module,
1118 domain,
1119 typehash,
1120 fee,
1121 Some(amount),
1122 Some(price),
1123 "old-order",
1124 )
1125 .unwrap(),
1126 )
1127 .unwrap()
1128 } else {
1129 serde_json::to_value(
1130 order_to_derive_payload(
1131 &order,
1132 &instrument,
1133 subaccount,
1134 wallet,
1135 &signer,
1136 nonce,
1137 expiry,
1138 module,
1139 domain,
1140 typehash,
1141 fee,
1142 None,
1143 )
1144 .unwrap(),
1145 )
1146 .unwrap()
1147 };
1148
1149 let transmitted = TradeModuleData {
1150 asset_address: instrument.base_asset_address.as_str().parse().unwrap(),
1151 sub_id: U256::from_str_radix(instrument.base_asset_sub_id.as_str(), 10).unwrap(),
1152 limit_price: wire["limit_price"].as_str().unwrap().parse().unwrap(),
1153 amount: wire["amount"].as_str().unwrap().parse().unwrap(),
1154 max_fee: wire["max_fee"].as_str().unwrap().parse().unwrap(),
1155 recipient_id: wire["subaccount_id"].as_u64().unwrap(),
1156 is_bid: wire["direction"] == "buy",
1157 };
1158
1159 assert_ne!(wallet, signer.address());
1160 assert_eq!(
1161 format!(
1162 "0x{}",
1163 alloy_primitives::hex::encode(transmitted.encode().unwrap())
1164 ),
1165 vector["module_data"]
1166 );
1167 assert_eq!(wire["nonce"], vector["nonce"]);
1168 assert_eq!(
1169 wire["signer"].as_str().unwrap().to_ascii_lowercase(),
1170 vector["signer"].as_str().unwrap().to_ascii_lowercase()
1171 );
1172 assert_eq!(wire["signature_expiry_sec"], vector["signature_expiry_sec"]);
1173 assert_eq!(wire["signature"], vector["signature"]);
1174 }
1175
1176 #[rstest]
1177 #[case("limit_price", false)]
1178 #[case("amount", false)]
1179 #[case("max_fee", false)]
1180 #[case("limit_price", true)]
1181 #[case("amount", true)]
1182 #[case("max_fee", true)]
1183 fn test_order_and_replace_reject_excess_precision(#[case] field: &str, #[case] replace: bool) {
1184 let order = build_test_limit_order(OrderSide::Buy, dec!(3500), dec!(1), false, false);
1185 let instrument = sample_perp_instrument();
1186 let signer = sample_signer();
1187
1188 let price = if field == "limit_price" {
1189 dec!(3500.0000000000001)
1190 } else {
1191 dec!(3500)
1192 };
1193
1194 let amount = if field == "amount" {
1195 dec!(1.0000000000001)
1196 } else {
1197 dec!(1)
1198 };
1199
1200 let fee = if field == "max_fee" {
1201 dec!(0.5000000000001)
1202 } else {
1203 dec!(0.5)
1204 };
1205
1206 let error = if replace {
1207 order_replace_to_derive_payload(
1208 &order,
1209 &instrument,
1210 30769,
1211 sample_wallet(),
1212 &signer,
1213 1_700_000_000_000_000_000,
1214 fresh_expiry_secs(),
1215 sample_module(),
1216 sample_domain(),
1217 sample_typehash(),
1218 fee,
1219 Some(amount),
1220 Some(price),
1221 "old-order",
1222 )
1223 .unwrap_err()
1224 } else {
1225 build_signed_order_params(
1226 &order,
1227 &instrument,
1228 30769,
1229 sample_wallet(),
1230 &signer,
1231 1_700_000_000_000_000_000,
1232 fresh_expiry_secs(),
1233 sample_module(),
1234 sample_domain(),
1235 sample_typehash(),
1236 fee,
1237 price,
1238 amount,
1239 DeriveOrderType::Limit,
1240 DeriveTimeInForce::Gtc,
1241 None,
1242 )
1243 .unwrap_err()
1244 };
1245
1246 let message = format!("{error:#}");
1247 assert!(message.contains(field), "{message}");
1248 assert!(
1249 message.contains("financial input exceeds 12 fractional digits"),
1250 "{message}"
1251 );
1252 }
1253
1254 #[rstest]
1255 fn test_order_params_wire_round_trip_omits_unset_optionals() {
1256 let params = DeriveOrderParams {
1257 envelope: fixed_envelope(123_456, "0xabc"),
1258 instrument_name: "ETH-PERP".into(),
1259 direction: DeriveOrderSide::Buy,
1260 order_type: DeriveOrderType::Limit,
1261 time_in_force: DeriveTimeInForce::Gtc,
1262 limit_price: dec!(3500.01),
1263 amount: dec!(1.25),
1264 max_fee: dec!(0.5),
1265 label: "client-1".to_string(),
1266 referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
1267 reduce_only: None,
1268 mmp: None,
1269 trigger_price: None,
1270 trigger_price_type: None,
1271 trigger_type: None,
1272 };
1273
1274 let debug = format!("{params:?}");
1275
1276 let wire = canonical_wire(¶ms);
1277 let expected = include_str!("../../test_data/common/private_order_params_limit.json")
1278 .trim_end_matches('\n');
1279 let round_trip: DeriveOrderParams = serde_json::from_str(&wire).unwrap();
1280
1281 assert_eq!(wire, expected);
1282 assert_eq!(round_trip, params);
1283 assert!(debug.contains(REDACTED));
1284 assert!(!debug.contains("0xabc"));
1285 }
1286
1287 #[rstest]
1288 fn test_replace_params_wire_round_trip_includes_set_optionals() {
1289 let params = DeriveReplaceParams {
1290 order: DeriveOrderParams {
1291 envelope: fixed_envelope(123_457, "0xdef"),
1292 instrument_name: "ETH-PERP".into(),
1293 direction: DeriveOrderSide::Sell,
1294 order_type: DeriveOrderType::Limit,
1295 time_in_force: DeriveTimeInForce::PostOnly,
1296 limit_price: dec!(3499.5),
1297 amount: dec!(2),
1298 max_fee: dec!(0),
1299 label: "client-2".to_string(),
1300 referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
1301 reduce_only: Some(true),
1302 mmp: Some(false),
1303 trigger_price: None,
1304 trigger_price_type: None,
1305 trigger_type: None,
1306 },
1307 order_id_to_cancel: "ord-stale-1".to_string(),
1308 expected_filled_amount: None,
1309 };
1310
1311 let wire = canonical_wire(¶ms);
1312 let expected =
1313 include_str!("../../test_data/common/private_replace_params_reduce_mmp.json")
1314 .trim_end_matches('\n');
1315 let round_trip: DeriveReplaceParams = serde_json::from_str(&wire).unwrap();
1316
1317 assert_eq!(wire, expected);
1318 assert_eq!(round_trip, params);
1319 }
1320
1321 #[rstest]
1322 fn test_history_params_wire_round_trip_omits_unset_filters() {
1323 let params = DeriveGetOrderHistoryParams::new(30769, 2, 500);
1324
1325 let wire = canonical_wire(¶ms);
1326 let expected =
1327 include_str!("../../test_data/common/private_order_history_params_required.json")
1328 .trim_end_matches('\n');
1329 let round_trip: DeriveGetOrderHistoryParams = serde_json::from_str(&wire).unwrap();
1330
1331 assert_eq!(wire, expected);
1332 assert_eq!(round_trip, params);
1333 }
1334
1335 #[rstest]
1336 fn test_trigger_order_params_wire_round_trip_includes_trigger_fields() {
1337 let params = DeriveTriggerOrderParams {
1338 order: DeriveOrderParams {
1339 envelope: fixed_envelope(123_458, "0xfeed"),
1340 instrument_name: "ETH-PERP".into(),
1341 direction: DeriveOrderSide::Sell,
1342 order_type: DeriveOrderType::Market,
1343 time_in_force: DeriveTimeInForce::Gtc,
1344 limit_price: dec!(3400),
1345 amount: dec!(0.1),
1346 max_fee: dec!(0.5),
1347 label: "client-stop-1".to_string(),
1348 referral_code: DERIVE_NAUTILUS_REFERRAL_CODE.to_string(),
1349 reduce_only: Some(true),
1350 mmp: None,
1351 trigger_price: Some(dec!(3450)),
1352 trigger_price_type: Some(DeriveTriggerPriceType::Mark),
1353 trigger_type: Some(DeriveTriggerType::Stoploss),
1354 },
1355 conn_id: "conn-1".to_string(),
1356 order_id: "trigger-order-1".to_string(),
1357 };
1358
1359 let wire = canonical_wire(¶ms);
1360 let expected =
1361 include_str!("../../test_data/common/private_trigger_order_params_stop_market.json")
1362 .trim_end_matches('\n');
1363 let round_trip: DeriveTriggerOrderParams = serde_json::from_str(&wire).unwrap();
1364
1365 assert_eq!(wire, expected);
1366 assert_eq!(round_trip.order, params.order);
1367 assert_eq!(round_trip.conn_id, "");
1368 assert_eq!(round_trip.order_id, "");
1369 }
1370
1371 fn sample_perp_instrument() -> DeriveInstrument {
1372 DeriveInstrument {
1375 amount_step: dec!(0.001),
1376 base_asset_address: "0x000000000000000000000000000000000000abcd".into(),
1377 base_asset_sub_id: "42".into(),
1378 base_currency: "ETH".into(),
1379 base_fee: dec!(0),
1380 instrument_name: "ETH-PERP".into(),
1381 instrument_type: DeriveInstrumentType::Perp,
1382 is_active: true,
1383 maker_fee_rate: dec!(0.0001),
1384 mark_price_fee_rate_cap: None,
1385 maximum_amount: dec!(1000),
1386 minimum_amount: dec!(0.001),
1387 option_details: None,
1388 perp_details: None,
1389 quote_currency: "USDC".into(),
1390 scheduled_activation: 0,
1391 scheduled_deactivation: 0,
1392 taker_fee_rate: dec!(0.0005),
1393 tick_size: dec!(0.01),
1394 raw: None,
1395 }
1396 }
1397
1398 fn sample_signer() -> PrivateKeySigner {
1399 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd"
1400 .parse()
1401 .unwrap()
1402 }
1403
1404 fn sample_wallet() -> Address {
1405 "0x000000000000000000000000000000000000aaaa"
1406 .parse()
1407 .unwrap()
1408 }
1409
1410 fn sample_module() -> Address {
1411 "0x000000000000000000000000000000000000bbbb"
1412 .parse()
1413 .unwrap()
1414 }
1415
1416 fn sample_domain() -> B256 {
1417 "0x2222222222222222222222222222222222222222222222222222222222222222"
1418 .parse()
1419 .unwrap()
1420 }
1421
1422 fn sample_typehash() -> B256 {
1423 "0x1111111111111111111111111111111111111111111111111111111111111111"
1424 .parse()
1425 .unwrap()
1426 }
1427
1428 fn fresh_expiry_secs() -> i64 {
1429 (SystemTime::now()
1430 .duration_since(UNIX_EPOCH)
1431 .unwrap()
1432 .as_secs() as i64)
1433 + 3600
1434 }
1435
1436 fn build_test_limit_order(
1437 side: OrderSide,
1438 price: Decimal,
1439 qty: Decimal,
1440 post_only: bool,
1441 reduce_only: bool,
1442 ) -> OrderAny {
1443 build_test_limit_order_with_time_in_force(
1444 side,
1445 price,
1446 qty,
1447 TimeInForce::Gtc,
1448 post_only,
1449 reduce_only,
1450 )
1451 }
1452
1453 fn build_test_limit_order_with_time_in_force(
1454 side: OrderSide,
1455 price: Decimal,
1456 qty: Decimal,
1457 time_in_force: TimeInForce,
1458 post_only: bool,
1459 reduce_only: bool,
1460 ) -> OrderAny {
1461 OrderAny::Limit(LimitOrder::new(
1462 TraderId::from("TRADER-001"),
1463 StrategyId::from("S-1"),
1464 InstrumentId::new(Symbol::new("ETH-PERP"), *DERIVE_VENUE),
1465 ClientOrderId::from("STRAT-PAYLOAD-1"),
1466 side,
1467 Quantity::from_decimal(qty).unwrap(),
1468 Price::from_decimal(price).unwrap(),
1469 time_in_force,
1470 None,
1471 post_only,
1472 reduce_only,
1473 false,
1474 None,
1475 None,
1476 None,
1477 None,
1478 None,
1479 None,
1480 None,
1481 None,
1482 None,
1483 None,
1484 None,
1485 UUID4::new(),
1486 UnixNanos::default(),
1487 ))
1488 }
1489
1490 fn build_test_stop_market_order() -> OrderAny {
1491 build_test_trigger_order(
1492 OrderType::StopMarket,
1493 OrderSide::Buy,
1494 None,
1495 TriggerType::Default,
1496 )
1497 }
1498
1499 fn build_test_trigger_order(
1500 order_type: OrderType,
1501 side: OrderSide,
1502 price: Option<Decimal>,
1503 trigger_type: TriggerType,
1504 ) -> OrderAny {
1505 let mut builder = OrderTestBuilder::new(order_type);
1506 builder
1507 .instrument_id(InstrumentId::new(Symbol::new("ETH-PERP"), *DERIVE_VENUE))
1508 .client_order_id(ClientOrderId::from("STRAT-PAYLOAD-STOP"))
1509 .side(side)
1510 .quantity(Quantity::from_decimal(dec!(1)).unwrap())
1511 .trigger_price(Price::from_decimal(dec!(3600)).unwrap())
1512 .trigger_type(trigger_type)
1513 .time_in_force(TimeInForce::Gtc);
1514
1515 if let Some(price) = price {
1516 builder.price(Price::from_decimal(price).unwrap());
1517 }
1518
1519 builder.build()
1520 }
1521
1522 fn build_test_market_order(side: OrderSide, qty: Decimal) -> OrderAny {
1523 OrderAny::Market(MarketOrder::new(
1524 TraderId::from("TRADER-001"),
1525 StrategyId::from("S-1"),
1526 InstrumentId::new(Symbol::new("ETH-PERP"), *DERIVE_VENUE),
1527 ClientOrderId::from("STRAT-PAYLOAD-MK"),
1528 side,
1529 Quantity::from_decimal(qty).unwrap(),
1530 TimeInForce::Ioc,
1531 UUID4::new(),
1532 UnixNanos::default(),
1533 false,
1534 false,
1535 None,
1536 None,
1537 None,
1538 None,
1539 None,
1540 None,
1541 None,
1542 None,
1543 ))
1544 }
1545
1546 #[rstest]
1547 fn test_order_to_derive_payload_limit_carries_all_required_fields() {
1548 let order = build_test_limit_order(OrderSide::Buy, dec!(3500), dec!(1), false, false);
1549 let instrument = sample_perp_instrument();
1550 let signer = sample_signer();
1551 let payload = order_to_derive_payload(
1552 &order,
1553 &instrument,
1554 30769,
1555 sample_wallet(),
1556 &signer,
1557 17_000_000_000_001,
1558 fresh_expiry_secs(),
1559 sample_module(),
1560 sample_domain(),
1561 sample_typehash(),
1562 dec!(1),
1563 None,
1564 )
1565 .map(to_value)
1566 .expect("payload built");
1567
1568 assert_eq!(payload["instrument_name"], "ETH-PERP");
1569 assert_eq!(payload["direction"], "buy");
1570 assert_eq!(payload["order_type"], "limit");
1571 assert_eq!(payload["time_in_force"], "gtc");
1572 assert_eq!(payload["label"], "STRAT-PAYLOAD-1");
1573 assert_eq!(payload["referral_code"], "nautilus");
1574 assert_eq!(payload["limit_price"], "3500");
1575 assert_eq!(payload["amount"], "1");
1576 assert_eq!(payload["max_fee"], "1");
1577 assert_eq!(payload["subaccount_id"], 30769);
1578 assert_eq!(payload["nonce"], "17000000000001");
1579 assert!(payload["signature_expiry_sec"].as_i64().unwrap() > 0);
1580 let signature = payload["signature"].as_str().unwrap();
1581 assert!(signature.starts_with("0x"));
1582 assert_eq!(signature.len(), 2 + 130, "65-byte sig = 132 hex chars");
1583 assert!(payload.get("reduce_only").is_none());
1584 assert!(payload.get("mmp").is_none());
1585 }
1586
1587 #[rstest]
1588 fn test_order_to_derive_payload_accepts_uint256_sub_id() {
1589 let order = build_test_limit_order(OrderSide::Buy, dec!(1), dec!(0.1), true, false);
1590 let mut instrument = sample_perp_instrument();
1591 instrument.instrument_name = "ETH-20260529-2200-C".into();
1592 instrument.base_asset_sub_id = "39614082202024973918552016768".into();
1593 instrument.instrument_type = DeriveInstrumentType::Option;
1594 let signer = sample_signer();
1595 let payload = order_to_derive_payload(
1596 &order,
1597 &instrument,
1598 30769,
1599 sample_wallet(),
1600 &signer,
1601 17_000_000_000_002,
1602 fresh_expiry_secs(),
1603 sample_module(),
1604 sample_domain(),
1605 sample_typehash(),
1606 dec!(1),
1607 None,
1608 )
1609 .map(to_value)
1610 .expect("payload built");
1611
1612 assert_eq!(payload["instrument_name"], "ETH-20260529-2200-C");
1613 assert_eq!(payload["time_in_force"], "post_only");
1614 assert!(payload["signature"].as_str().unwrap().starts_with("0x"));
1615 }
1616
1617 #[rstest]
1618 #[case(TimeInForce::Gtc, false, "gtc")]
1619 #[case(TimeInForce::Ioc, false, "ioc")]
1620 #[case(TimeInForce::Fok, false, "fok")]
1621 #[case(TimeInForce::Gtc, true, "post_only")]
1622 fn test_order_to_derive_payload_carries_supported_time_in_force(
1623 #[case] time_in_force: TimeInForce,
1624 #[case] post_only: bool,
1625 #[case] expected: &str,
1626 ) {
1627 let order = build_test_limit_order_with_time_in_force(
1628 OrderSide::Buy,
1629 dec!(3500),
1630 dec!(1),
1631 time_in_force,
1632 post_only,
1633 false,
1634 );
1635 let instrument = sample_perp_instrument();
1636 let signer = sample_signer();
1637 let payload = order_to_derive_payload(
1638 &order,
1639 &instrument,
1640 30769,
1641 sample_wallet(),
1642 &signer,
1643 17_000_000_000_002,
1644 fresh_expiry_secs(),
1645 sample_module(),
1646 sample_domain(),
1647 sample_typehash(),
1648 dec!(0),
1649 None,
1650 )
1651 .map(to_value)
1652 .expect("payload built");
1653
1654 assert_eq!(payload["time_in_force"], expected);
1655 }
1656
1657 #[rstest]
1658 #[case(TimeInForce::Ioc, false, true, "ioc", Some(true), None)]
1659 #[case(TimeInForce::Gtc, true, false, "post_only", None, Some(false))]
1660 fn test_order_to_derive_payload_emits_supported_reduce_only_and_mmp_flags(
1661 #[case] time_in_force: TimeInForce,
1662 #[case] post_only: bool,
1663 #[case] reduce_only: bool,
1664 #[case] expected_tif: &str,
1665 #[case] expected_reduce_only: Option<bool>,
1666 #[case] expected_mmp: Option<bool>,
1667 ) {
1668 let order = build_test_limit_order_with_time_in_force(
1669 OrderSide::Sell,
1670 dec!(3500),
1671 dec!(1),
1672 time_in_force,
1673 post_only,
1674 reduce_only,
1675 );
1676 let instrument = sample_perp_instrument();
1677 let signer = sample_signer();
1678 let payload = order_to_derive_payload(
1679 &order,
1680 &instrument,
1681 30769,
1682 sample_wallet(),
1683 &signer,
1684 17_000_000_000_002,
1685 fresh_expiry_secs(),
1686 sample_module(),
1687 sample_domain(),
1688 sample_typehash(),
1689 dec!(0),
1690 None,
1691 )
1692 .map(to_value)
1693 .expect("payload built");
1694
1695 assert_eq!(payload["direction"], "sell");
1696 assert_eq!(payload["time_in_force"], expected_tif);
1697 assert_eq!(
1698 payload["reduce_only"],
1699 serde_json::to_value(expected_reduce_only).unwrap()
1700 );
1701 assert_eq!(payload["mmp"], serde_json::to_value(expected_mmp).unwrap());
1702 }
1703
1704 #[rstest]
1705 fn test_order_to_derive_payload_market_uses_explicit_price_override() {
1706 let order = build_test_market_order(OrderSide::Buy, dec!(0.5));
1707 let instrument = sample_perp_instrument();
1708 let signer = sample_signer();
1709 let payload = order_to_derive_payload(
1710 &order,
1711 &instrument,
1712 30769,
1713 sample_wallet(),
1714 &signer,
1715 17_000_000_000_003,
1716 fresh_expiry_secs(),
1717 sample_module(),
1718 sample_domain(),
1719 sample_typehash(),
1720 dec!(0),
1721 Some(dec!(3519)),
1722 )
1723 .map(to_value)
1724 .expect("payload built");
1725
1726 assert_eq!(payload["order_type"], "market");
1727 assert_eq!(payload["limit_price"], "3519");
1728 }
1729
1730 #[rstest]
1731 fn test_order_to_derive_payload_market_without_explicit_price_errors() {
1732 let order = build_test_market_order(OrderSide::Buy, dec!(0.5));
1733 let instrument = sample_perp_instrument();
1734 let signer = sample_signer();
1735 let err = order_to_derive_payload(
1736 &order,
1737 &instrument,
1738 30769,
1739 sample_wallet(),
1740 &signer,
1741 17_000_000_000_004,
1742 fresh_expiry_secs(),
1743 sample_module(),
1744 sample_domain(),
1745 sample_typehash(),
1746 dec!(0),
1747 None,
1748 )
1749 .expect_err("market without price must error");
1750
1751 assert!(
1752 err.to_string().contains("missing limit price"),
1753 "unexpected error: {err}",
1754 );
1755 }
1756
1757 #[rstest]
1758 #[case(TimeInForce::Day, false, "UNSUPPORTED_TIME_IN_FORCE")]
1759 #[case(TimeInForce::Day, true, "UNSUPPORTED_TIME_IN_FORCE")]
1760 #[case(TimeInForce::Ioc, true, "UNSUPPORTED_TIME_IN_FORCE")]
1761 #[case(TimeInForce::Fok, true, "UNSUPPORTED_TIME_IN_FORCE")]
1762 fn test_order_to_derive_payload_rejects_unsupported_tif(
1763 #[case] time_in_force: TimeInForce,
1764 #[case] post_only: bool,
1765 #[case] reason_fragment: &str,
1766 ) {
1767 let order = build_test_limit_order_with_time_in_force(
1768 OrderSide::Buy,
1769 dec!(3500),
1770 dec!(1),
1771 time_in_force,
1772 post_only,
1773 false,
1774 );
1775 let instrument = sample_perp_instrument();
1776 let signer = sample_signer();
1777 let err = order_to_derive_payload(
1778 &order,
1779 &instrument,
1780 30769,
1781 sample_wallet(),
1782 &signer,
1783 17_000_000_000_005,
1784 fresh_expiry_secs(),
1785 sample_module(),
1786 sample_domain(),
1787 sample_typehash(),
1788 dec!(0),
1789 None,
1790 )
1791 .expect_err("unsupported TIF must error");
1792
1793 assert!(
1794 err.to_string().contains(reason_fragment),
1795 "unexpected error: {err}",
1796 );
1797 }
1798
1799 #[rstest]
1800 fn test_order_to_derive_payload_rejects_stop_order_before_price_resolution() {
1801 let order = build_test_stop_market_order();
1802 let instrument = sample_perp_instrument();
1803 let signer = sample_signer();
1804 let err = order_to_derive_payload(
1805 &order,
1806 &instrument,
1807 30769,
1808 sample_wallet(),
1809 &signer,
1810 17_000_000_000_006,
1811 fresh_expiry_secs(),
1812 sample_module(),
1813 sample_domain(),
1814 sample_typehash(),
1815 dec!(0),
1816 None,
1817 )
1818 .expect_err("unsupported order type must error");
1819
1820 assert!(
1821 err.to_string().contains("UNSUPPORTED_ORDER_TYPE"),
1822 "unexpected error: {err}",
1823 );
1824 assert!(
1825 !err.to_string().contains("missing limit price"),
1826 "unexpected error: {err}",
1827 );
1828 }
1829
1830 #[rstest]
1831 fn test_trigger_order_to_derive_payload_stop_market_uses_mark_trigger() {
1832 let order = build_test_trigger_order(
1833 OrderType::StopMarket,
1834 OrderSide::Sell,
1835 None,
1836 TriggerType::MarkPrice,
1837 );
1838 let instrument = sample_perp_instrument();
1839 let signer = sample_signer();
1840 let payload = trigger_order_to_derive_payload(
1841 &order,
1842 &instrument,
1843 30769,
1844 sample_wallet(),
1845 &signer,
1846 17_000_000_000_007,
1847 fresh_expiry_secs(),
1848 sample_module(),
1849 sample_domain(),
1850 sample_typehash(),
1851 dec!(1),
1852 Some(dec!(3400)),
1853 "conn-1",
1854 "trigger-1",
1855 )
1856 .map(to_value)
1857 .expect("trigger payload built");
1858
1859 assert_eq!(payload.get("conn_id"), None);
1860 assert_eq!(payload.get("order_id"), None);
1861 assert_eq!(payload["direction"], "sell");
1862 assert_eq!(payload["order_type"], "market");
1863 assert_eq!(payload["limit_price"], "3400");
1864 assert_eq!(payload["trigger_price"], "3600");
1865 assert_eq!(payload["trigger_price_type"], "mark");
1866 assert_eq!(payload["trigger_type"], "stoploss");
1867 }
1868
1869 #[rstest]
1870 fn test_trigger_order_to_derive_payload_stop_limit_maps_limit_stoploss() {
1871 let order = build_test_trigger_order(
1872 OrderType::StopLimit,
1873 OrderSide::Sell,
1874 Some(dec!(3500)),
1875 TriggerType::MarkPrice,
1876 );
1877 let instrument = sample_perp_instrument();
1878 let signer = sample_signer();
1879 let payload = trigger_order_to_derive_payload(
1880 &order,
1881 &instrument,
1882 30769,
1883 sample_wallet(),
1884 &signer,
1885 17_000_000_000_008,
1886 fresh_expiry_secs(),
1887 sample_module(),
1888 sample_domain(),
1889 sample_typehash(),
1890 dec!(1),
1891 None,
1892 "conn-1",
1893 "trigger-2",
1894 )
1895 .map(to_value)
1896 .expect("trigger payload built");
1897
1898 assert_eq!(payload["order_type"], "limit");
1899 assert_eq!(payload["limit_price"], "3500");
1900 assert_eq!(payload["trigger_type"], "stoploss");
1901 }
1902
1903 #[rstest]
1904 #[case(
1905 OrderType::MarketIfTouched,
1906 DeriveOrderType::Market,
1907 DeriveTriggerType::Takeprofit
1908 )]
1909 #[case(
1910 OrderType::LimitIfTouched,
1911 DeriveOrderType::Limit,
1912 DeriveTriggerType::Takeprofit
1913 )]
1914 fn test_trigger_order_to_derive_payload_take_profit_types(
1915 #[case] order_type: OrderType,
1916 #[case] expected_order_type: DeriveOrderType,
1917 #[case] expected_trigger_type: DeriveTriggerType,
1918 ) {
1919 let price = if order_type == OrderType::LimitIfTouched {
1920 Some(dec!(3700))
1921 } else {
1922 None
1923 };
1924
1925 let explicit_price = if price.is_none() {
1926 Some(dec!(3705))
1927 } else {
1928 None
1929 };
1930
1931 let order =
1932 build_test_trigger_order(order_type, OrderSide::Buy, price, TriggerType::MarkPrice);
1933 let instrument = sample_perp_instrument();
1934 let signer = sample_signer();
1935 let payload = trigger_order_to_derive_payload(
1936 &order,
1937 &instrument,
1938 30769,
1939 sample_wallet(),
1940 &signer,
1941 17_000_000_000_009,
1942 fresh_expiry_secs(),
1943 sample_module(),
1944 sample_domain(),
1945 sample_typehash(),
1946 dec!(1),
1947 explicit_price,
1948 "conn-1",
1949 "trigger-3",
1950 )
1951 .expect("trigger payload built");
1952
1953 assert_eq!(payload.order.order_type, expected_order_type);
1954 assert_eq!(payload.order.trigger_type, Some(expected_trigger_type));
1955 }
1956
1957 #[rstest]
1958 fn test_trigger_order_to_derive_payload_rejects_index_trigger_price_type() {
1959 let order = build_test_trigger_order(
1960 OrderType::StopMarket,
1961 OrderSide::Buy,
1962 None,
1963 TriggerType::IndexPrice,
1964 );
1965 let instrument = sample_perp_instrument();
1966 let signer = sample_signer();
1967 let err = trigger_order_to_derive_payload(
1968 &order,
1969 &instrument,
1970 30769,
1971 sample_wallet(),
1972 &signer,
1973 17_000_000_000_010,
1974 fresh_expiry_secs(),
1975 sample_module(),
1976 sample_domain(),
1977 sample_typehash(),
1978 dec!(1),
1979 Some(dec!(3618)),
1980 "conn-1",
1981 "trigger-4",
1982 )
1983 .expect_err("index trigger price type must fail");
1984
1985 assert!(
1986 err.to_string()
1987 .contains("Derive currently accepts only MarkPrice"),
1988 "unexpected error: {err}",
1989 );
1990 }
1991
1992 #[rstest]
1993 fn test_trigger_order_to_derive_payload_maps_default_trigger_type_to_mark() {
1994 let order = build_test_trigger_order(
1995 OrderType::StopMarket,
1996 OrderSide::Buy,
1997 None,
1998 TriggerType::Default,
1999 );
2000 let instrument = sample_perp_instrument();
2001 let signer = sample_signer();
2002 let payload = trigger_order_to_derive_payload(
2003 &order,
2004 &instrument,
2005 30769,
2006 sample_wallet(),
2007 &signer,
2008 17_000_000_000_011,
2009 fresh_expiry_secs(),
2010 sample_module(),
2011 sample_domain(),
2012 sample_typehash(),
2013 dec!(1),
2014 Some(dec!(3618)),
2015 "conn-1",
2016 "trigger-5",
2017 )
2018 .expect("default trigger type should map to mark");
2019
2020 assert_eq!(
2021 payload.order.trigger_price_type,
2022 Some(DeriveTriggerPriceType::Mark),
2023 );
2024 }
2025
2026 #[rstest]
2027 fn test_order_replace_to_derive_payload_stamps_cancel_clause_and_overrides() {
2028 let order = build_test_limit_order(OrderSide::Buy, dec!(3500), dec!(1), false, false);
2029 let instrument = sample_perp_instrument();
2030 let signer = sample_signer();
2031 let payload = order_replace_to_derive_payload(
2032 &order,
2033 &instrument,
2034 30769,
2035 sample_wallet(),
2036 &signer,
2037 17_000_000_000_010,
2038 fresh_expiry_secs(),
2039 sample_module(),
2040 sample_domain(),
2041 sample_typehash(),
2042 dec!(1),
2043 Some(dec!(2)),
2044 Some(dec!(3505)),
2045 "ord-stale-1",
2046 )
2047 .map(to_value)
2048 .expect("replace payload built");
2049
2050 assert_eq!(payload["order_id_to_cancel"], "ord-stale-1");
2051 assert_eq!(payload["amount"], "2");
2052 assert_eq!(payload["limit_price"], "3505");
2053 assert_eq!(payload["direction"], "buy");
2054 assert_eq!(payload["order_type"], "limit");
2055 assert_eq!(payload["time_in_force"], "gtc");
2056 assert_eq!(payload["label"], "STRAT-PAYLOAD-1");
2057 assert_eq!(payload["subaccount_id"], 30769);
2058 assert_eq!(payload["nonce"], "17000000000010");
2059 let signature = payload["signature"].as_str().unwrap();
2060 assert!(signature.starts_with("0x"));
2061 assert_eq!(signature.len(), 2 + 130);
2062 }
2063
2064 #[rstest]
2065 fn test_order_replace_to_derive_payload_falls_back_to_cached_quantity_and_price() {
2066 let order = build_test_limit_order(OrderSide::Sell, dec!(3501), dec!(0.5), false, false);
2067 let instrument = sample_perp_instrument();
2068 let signer = sample_signer();
2069 let payload = order_replace_to_derive_payload(
2070 &order,
2071 &instrument,
2072 30769,
2073 sample_wallet(),
2074 &signer,
2075 17_000_000_000_011,
2076 fresh_expiry_secs(),
2077 sample_module(),
2078 sample_domain(),
2079 sample_typehash(),
2080 dec!(0),
2081 None,
2082 None,
2083 "ord-stale-2",
2084 )
2085 .map(to_value)
2086 .expect("replace payload built");
2087
2088 assert_eq!(payload["order_id_to_cancel"], "ord-stale-2");
2089 assert_eq!(payload["amount"], "0.5");
2090 assert_eq!(payload["limit_price"], "3501");
2091 assert_eq!(payload["direction"], "sell");
2092 }
2093
2094 #[rstest]
2095 fn test_order_replace_to_derive_payload_market_without_explicit_price_errors() {
2096 let order = build_test_market_order(OrderSide::Buy, dec!(0.5));
2097 let instrument = sample_perp_instrument();
2098 let signer = sample_signer();
2099 let err = order_replace_to_derive_payload(
2100 &order,
2101 &instrument,
2102 30769,
2103 sample_wallet(),
2104 &signer,
2105 17_000_000_000_012,
2106 fresh_expiry_secs(),
2107 sample_module(),
2108 sample_domain(),
2109 sample_typehash(),
2110 dec!(0),
2111 None,
2112 None,
2113 "ord-stale-3",
2114 )
2115 .expect_err("market replace without price must error");
2116
2117 assert!(
2118 err.to_string().contains("missing limit price"),
2119 "unexpected error: {err}",
2120 );
2121 }
2122
2123 #[rstest]
2124 fn test_order_replace_to_derive_payload_rejects_stop_order_before_price_resolution() {
2125 let order = build_test_stop_market_order();
2126 let instrument = sample_perp_instrument();
2127 let signer = sample_signer();
2128 let err = order_replace_to_derive_payload(
2129 &order,
2130 &instrument,
2131 30769,
2132 sample_wallet(),
2133 &signer,
2134 17_000_000_000_013,
2135 fresh_expiry_secs(),
2136 sample_module(),
2137 sample_domain(),
2138 sample_typehash(),
2139 dec!(0),
2140 None,
2141 None,
2142 "ord-stale-4",
2143 )
2144 .expect_err("unsupported order type must error");
2145
2146 assert!(
2147 err.to_string().contains("UNSUPPORTED_ORDER_TYPE"),
2148 "unexpected error: {err}",
2149 );
2150 assert!(
2151 !err.to_string().contains("missing limit price"),
2152 "unexpected error: {err}",
2153 );
2154 }
2155}