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 is not a Limit or Market order, when the instrument's
483/// `base_asset_address` cannot be parsed, when decimal scaling fails, when a Market order is
484/// submitted without an `explicit_price`, or when 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 is not one of StopMarket, StopLimit, MarketIfTouched, or
533/// LimitIfTouched, when the trigger source is not MarkPrice, when required prices are absent, or
534/// when 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 is not a Limit or Market order, when the instrument's
606/// `base_asset_address` cannot be parsed, when decimal scaling fails, when a Market order has no
607/// `explicit_price`, or when 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) -> 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(&params);
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(&params);
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(&params);
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(&params);
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        // Manually-constructed instrument record that satisfies signing's
1373        // address/sub-id parsing without depending on an on-disk fixture.
1374        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}