Skip to main content

nautilus_derive/http/
query.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Typed JSON-RPC params for Derive private execution endpoints.
17
18use 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/// Signed EIP-712 envelope shared by `private/order` and `private/replace`.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
64pub struct DeriveSignedEnvelope {
65    /// Owning subaccount identifier.
66    pub subaccount_id: u64,
67    /// Per-action UNIX nanosecond nonce, encoded as a decimal string.
68    #[serde(
69        serialize_with = "serialize_nonce",
70        deserialize_with = "deserialize_nonce"
71    )]
72    pub nonce: u64,
73    /// Session-key signer address.
74    pub signer: String,
75    /// Signature expiry in UNIX seconds.
76    pub signature_expiry_sec: i64,
77    /// 65-byte EIP-712 signature as `0x`-prefixed hex.
78    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/// Params for `private/order`.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
96pub struct DeriveOrderParams {
97    /// Signed action envelope.
98    #[serde(flatten)]
99    pub envelope: DeriveSignedEnvelope,
100    /// Canonical Derive instrument name.
101    pub instrument_name: Ustr,
102    /// Order side.
103    pub direction: DeriveOrderSide,
104    /// Order type.
105    pub order_type: DeriveOrderType,
106    /// Time-in-force.
107    pub time_in_force: DeriveTimeInForce,
108    /// Signed limit price.
109    #[serde(
110        serialize_with = "serialize_decimal_as_str",
111        deserialize_with = "deserialize_decimal"
112    )]
113    pub limit_price: Decimal,
114    /// Signed order amount.
115    #[serde(
116        serialize_with = "serialize_decimal_as_str",
117        deserialize_with = "deserialize_decimal"
118    )]
119    pub amount: Decimal,
120    /// Signed per-contract fee cap.
121    #[serde(
122        serialize_with = "serialize_decimal_as_str",
123        deserialize_with = "deserialize_decimal"
124    )]
125    pub max_fee: Decimal,
126    /// User label, mapped from Nautilus client order id.
127    pub label: String,
128    /// Nautilus referral code.
129    pub referral_code: String,
130    /// Reduce-only flag, omitted unless set.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub reduce_only: Option<bool>,
133    /// MMP flag, omitted unless set.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub mmp: Option<bool>,
136    /// Trigger price for `private/order`; omitted for normal orders.
137    #[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    /// Trigger price source for `private/order`; omitted for normal orders.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub trigger_price_type: Option<DeriveTriggerPriceType>,
147    /// Trigger side for `private/order`; omitted for normal orders.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub trigger_type: Option<DeriveTriggerType>,
150}
151
152/// Trigger order params for `private/order`.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
154pub struct DeriveTriggerOrderParams {
155    /// New signed trigger order body.
156    #[serde(flatten)]
157    pub order: DeriveOrderParams,
158    /// Legacy connection id; omitted from v3 requests.
159    #[serde(skip)]
160    pub conn_id: String,
161    /// Legacy client-supplied venue id; omitted from v3 requests.
162    #[serde(skip)]
163    pub order_id: String,
164}
165
166/// Params for `private/replace`.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
168pub struct DeriveReplaceParams {
169    /// New signed order body.
170    #[serde(flatten)]
171    pub order: DeriveOrderParams,
172    /// Venue order id to atomically cancel.
173    pub order_id_to_cancel: String,
174    /// Exact current-leg fill ceiling; Derive rejects the replace if fills exceed it.
175    /// See <https://docs.derive.xyz/trading/order-types#replace-atomic-cancel-+-replace>.
176    #[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/// Params for `private/cancel_trigger_order`.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
187pub struct DeriveCancelTriggerOrderParams {
188    /// Owning subaccount identifier.
189    pub subaccount_id: u64,
190    /// Venue order id.
191    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/// Params for `private/cancel`.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
206pub struct DeriveCancelParams {
207    /// Owning subaccount identifier.
208    pub subaccount_id: u64,
209    /// Canonical Derive instrument name.
210    pub instrument_name: Ustr,
211    /// Venue order id.
212    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/// Params for `private/cancel_by_instrument`.
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
232pub struct DeriveCancelByInstrumentParams {
233    /// Owning subaccount identifier.
234    pub subaccount_id: u64,
235    /// Canonical Derive instrument name.
236    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/// Params for `private/cancel_all`.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
251pub struct DeriveCancelAllParams {
252    /// Owning subaccount identifier.
253    pub subaccount_id: u64,
254    /// Optional instrument scope.
255    #[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/// Params for `private/cancel_by_label`.
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
277pub struct DeriveCancelByLabelParams {
278    /// Owning subaccount identifier.
279    pub subaccount_id: u64,
280    /// User label to cancel.
281    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/// Params for `private/get_subaccount`.
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
296pub struct DeriveGetSubaccountParams {
297    /// Owning subaccount identifier.
298    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/// Params for `private/get_open_orders`.
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
310pub struct DeriveGetOpenOrdersParams {
311    /// Owning subaccount identifier.
312    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/// Params for `private/get_trigger_orders`.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
324pub struct DeriveGetTriggerOrdersParams {
325    /// Owning subaccount identifier.
326    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/// Params for `private/get_order`.
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
338pub struct DeriveGetOrderParams {
339    /// Owning subaccount identifier.
340    pub subaccount_id: u64,
341    /// Venue order id.
342    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/// Params for `private/get_order_history`.
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
357pub struct DeriveGetOrderHistoryParams {
358    /// Owning subaccount identifier.
359    pub subaccount_id: u64,
360    /// Optional instrument scope.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub instrument_name: Option<Ustr>,
363    /// Optional inclusive lower timestamp bound in UNIX milliseconds.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub from_timestamp: Option<i64>,
366    /// Optional inclusive upper timestamp bound in UNIX milliseconds.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub to_timestamp: Option<i64>,
369    /// 1-indexed page number.
370    pub page: u32,
371    /// Page size.
372    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/// Params for `private/get_trade_history`.
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
408pub struct DeriveGetTradeHistoryParams {
409    /// Owning subaccount identifier.
410    pub subaccount_id: u64,
411    /// Optional instrument scope.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub instrument_name: Option<Ustr>,
414    /// Optional inclusive lower timestamp bound in UNIX milliseconds.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub from_timestamp: Option<i64>,
417    /// Optional inclusive upper timestamp bound in UNIX milliseconds.
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub to_timestamp: Option<i64>,
420    /// 1-indexed page number.
421    pub page: u32,
422    /// Page size.
423    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/// Params for `private/get_positions`.
458#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
459pub struct DeriveGetPositionsParams {
460    /// Owning subaccount identifier.
461    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/// Builds typed params for a signed `private/order` request.
472///
473/// `wallet` is the owner address, `signer_address` the session-key address,
474/// and `nonce` / `signature_expiry_sec` come from
475/// [`crate::signing::nonce::NonceManager`] and the configured expiry policy.
476/// `explicit_price` overrides the limit price slot. Callers must supply it
477/// for market orders because Derive signs the worst-acceptable price into the
478/// EIP-712 trade module data.
479///
480/// # Errors
481///
482/// Returns an error if the order quantity is quote-denominated, the order type or flags are
483/// unsupported, the instrument's `base_asset_address` cannot be parsed, decimal scaling fails,
484/// a Market order has no `explicit_price`, or EIP-712 signing fails.
485#[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/// Builds typed params for a signed trigger `private/order` request.
526///
527/// Derive assigns the order id and stores the order off-book until triggered.
528/// The legacy `conn_id` and `order_id` arguments are omitted from the wire.
529///
530/// # Errors
531///
532/// Returns an error if the order quantity is quote-denominated, the order type, flags, or trigger
533/// source are unsupported, required prices are absent, the instrument's `base_asset_address` cannot
534/// be parsed, decimal scaling fails, or EIP-712 signing fails.
535#[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/// Builds typed params for a signed `private/replace` request.
598///
599/// Derive's replace endpoint atomically cancels a stale order and submits a
600/// new signed order. The new-order half is signed against
601/// [`TradeModuleData`] exactly like `private/order`.
602///
603/// # Errors
604///
605/// Returns an error if the order quantity is quote-denominated, even with `explicit_quantity`,
606/// the order type or flags are unsupported, the instrument's `base_asset_address` cannot be parsed,
607/// decimal scaling fails, a Market order has no `explicit_price`, or EIP-712 signing fails.
608#[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(&params);
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(&params);
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(&params);
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(&params);
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        // Manually-constructed instrument record that satisfies signing's
1378        // address/sub-id parsing without depending on an on-disk fixture.
1379        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}