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