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