Skip to main content

nautilus_lighter/signing/tx/
types.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//! Strongly typed Lighter L2 transaction payloads.
17//!
18//! Each tx-info struct carries the fields the venue's sequencer reduces into
19//! the signed message. The trait [`LighterTx`] expresses the two pieces of
20//! per-tx-type information the encoder needs: the discriminant (`tx_type`)
21//! and the body element sequence used as the Poseidon2 preimage.
22//!
23//! Field order in [`LighterTx::push_body_elements`] is locked: it matches
24//! the `Hash(lighterChainId)` method on each `txtypes.L2*TxInfo` Go struct.
25//! Any reordering breaks the byte-equality property the Layer 2 oracle
26//! vectors enforce.
27
28use crate::{
29    common::enums::{
30        LighterGroupingType, LighterSelfTradeBehaviorMode, LighterSelfTradeEqualityMode,
31        LighterTxType,
32    },
33    signing::{
34        field::Fp,
35        hash::{HASH_OUT, hash_no_pad, hash_two_to_one},
36    },
37};
38
39/// Number of attribute slots reserved per L2 transaction.
40///
41/// Mirrors the upstream `NbAttributesPerTx` constant; the attribute hash is
42/// padded out to this width so its preimage shape is the same regardless of
43/// how many attributes are populated.
44pub(super) const NB_ATTRIBUTES_PER_TX: usize = 4;
45
46const ATTR_TYPE_INTEGRATOR_ACCOUNT_INDEX: u8 = 1;
47const ATTR_TYPE_INTEGRATOR_TAKER_FEE: u8 = 2;
48const ATTR_TYPE_INTEGRATOR_MAKER_FEE: u8 = 3;
49const ATTR_TYPE_SKIP_NONCE: u8 = 4;
50const ATTR_TYPE_CANCEL_ALL_MARKET_INDEX: u8 = 5;
51const ATTR_TYPE_SELF_TRADE_BEHAVIOR_MODE: u8 = 6;
52const ATTR_TYPE_SELF_TRADE_EQUALITY_MODE: u8 = 7;
53const NIL_MARKET_INDEX: i64 = 255;
54
55/// Per-transaction L2 attributes.
56///
57/// Default-valued attributes do not contribute to the signed hash. Explicit
58/// self-trade defaults are still emitted in JSON and count toward the four-attribute limit.
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
60pub struct L2TxAttributes {
61    /// Integrator account index.
62    pub integrator_account_index: u64,
63    /// Integrator taker fee in fee-tick units.
64    pub integrator_taker_fee: u32,
65    /// Integrator maker fee in fee-tick units.
66    pub integrator_maker_fee: u32,
67    /// `1` to instruct the sequencer to skip nonce bookkeeping for this tx.
68    pub skip_nonce: u8,
69    /// Cancel-all market attribute; `None` or `255` does not contribute to the hash.
70    /// `CancelAllOrdersTxInfo` does not carry this attribute.
71    pub cancel_all_market_index: Option<i64>,
72    /// Self-trade action; `None` leaves the venue default in force.
73    pub self_trade_behavior_mode: Option<LighterSelfTradeBehaviorMode>,
74    /// Self-trade account scope; `None` leaves the venue default in force.
75    pub self_trade_equality_mode: Option<LighterSelfTradeEqualityMode>,
76}
77
78impl L2TxAttributes {
79    /// Returns true when no attribute contributes to the hash.
80    ///
81    /// Explicit nil values can still occupy JSON entries. Encoders use only
82    /// the body hash when this returns true, matching upstream `IsEmpty`.
83    #[must_use]
84    pub fn is_empty(&self) -> bool {
85        self.integrator_account_index == 0
86            && self.integrator_taker_fee == 0
87            && self.integrator_maker_fee == 0
88            && self.skip_nonce == 0
89            && self
90                .cancel_all_market_index
91                .is_none_or(|v| v == NIL_MARKET_INDEX)
92            && self
93                .self_trade_behavior_mode
94                .is_none_or(|v| v == LighterSelfTradeBehaviorMode::default())
95            && self
96                .self_trade_equality_mode
97                .is_none_or(|v| v == LighterSelfTradeEqualityMode::default())
98    }
99
100    /// Checks attribute capacity and the venue's self-trade restrictions.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if:
105    /// - The transaction exceeds the attribute limit.
106    /// - The cancel-all market index is invalid.
107    /// - Non-default self-trade modes combine with non-zero integrator fees.
108    /// - Reduce behavior combines with master-account equality.
109    pub fn validate(&self) -> anyhow::Result<()> {
110        let count = usize::from(self.integrator_account_index != 0)
111            + usize::from(self.integrator_taker_fee != 0)
112            + usize::from(self.integrator_maker_fee != 0)
113            + usize::from(self.skip_nonce != 0)
114            + usize::from(self.cancel_all_market_index.is_some())
115            + usize::from(self.self_trade_behavior_mode.is_some())
116            + usize::from(self.self_trade_equality_mode.is_some());
117        anyhow::ensure!(
118            count <= NB_ATTRIBUTES_PER_TX,
119            "Lighter permits at most {NB_ATTRIBUTES_PER_TX} transaction attributes"
120        );
121
122        if let Some(index) = self.cancel_all_market_index {
123            anyhow::ensure!(
124                (0..1_i64 << 48).contains(&index),
125                "invalid Lighter cancel-all market index {index}"
126            );
127        }
128
129        let has_self_trade = self
130            .self_trade_behavior_mode
131            .is_some_and(|v| v != LighterSelfTradeBehaviorMode::default())
132            || self
133                .self_trade_equality_mode
134                .is_some_and(|v| v != LighterSelfTradeEqualityMode::default());
135        anyhow::ensure!(
136            !has_self_trade || (self.integrator_taker_fee == 0 && self.integrator_maker_fee == 0),
137            "Lighter self-trade modes cannot combine with non-zero integrator fees"
138        );
139        anyhow::ensure!(
140            self.self_trade_behavior_mode != Some(LighterSelfTradeBehaviorMode::Reduce)
141                || self.self_trade_equality_mode
142                    != Some(LighterSelfTradeEqualityMode::MasterAccountIndex),
143            "Lighter reduce self-trade behavior cannot combine with master-account equality"
144        );
145        Ok(())
146    }
147
148    /// Returns the (type, value) pairs in the same ascending order
149    /// `getNormalizedTypes` produces upstream, padded with zeros to
150    /// [`NB_ATTRIBUTES_PER_TX`] entries.
151    pub(super) fn normalized_pairs(&self) -> anyhow::Result<[(u8, u64); NB_ATTRIBUTES_PER_TX]> {
152        self.validate()?;
153        let mut filled = [(0u8, 0u64); NB_ATTRIBUTES_PER_TX];
154        let mut i = 0;
155        let mut push = |ty: u8, val: u64| {
156            if val != 0 {
157                filled[i] = (ty, val);
158                i += 1;
159            }
160        };
161        push(
162            ATTR_TYPE_INTEGRATOR_ACCOUNT_INDEX,
163            self.integrator_account_index,
164        );
165        push(
166            ATTR_TYPE_INTEGRATOR_TAKER_FEE,
167            u64::from(self.integrator_taker_fee),
168        );
169        push(
170            ATTR_TYPE_INTEGRATOR_MAKER_FEE,
171            u64::from(self.integrator_maker_fee),
172        );
173        push(ATTR_TYPE_SKIP_NONCE, u64::from(self.skip_nonce));
174
175        if let Some(index) = self
176            .cancel_all_market_index
177            .filter(|v| *v != NIL_MARKET_INDEX)
178        {
179            filled[i] = (ATTR_TYPE_CANCEL_ALL_MARKET_INDEX, index as u64);
180            i += 1;
181        }
182
183        if let Some(mode) = self
184            .self_trade_behavior_mode
185            .filter(|v| *v != LighterSelfTradeBehaviorMode::default())
186        {
187            filled[i] = (ATTR_TYPE_SELF_TRADE_BEHAVIOR_MODE, u64::from(mode as u8));
188            i += 1;
189        }
190
191        if let Some(mode) = self
192            .self_trade_equality_mode
193            .filter(|v| *v != LighterSelfTradeEqualityMode::default())
194        {
195            filled[i] = (ATTR_TYPE_SELF_TRADE_EQUALITY_MODE, u64::from(mode as u8));
196        }
197
198        Ok(filled)
199    }
200}
201
202/// Common per-order body fields shared by [`CreateOrderTxInfo`] and [`CreateGroupedOrdersTxInfo`].
203#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
204pub struct OrderInfo {
205    /// Market identifier in the venue's 64-bit allocation. Legacy markets keep
206    /// their range-partitioned ids (perps `0..=254`, spot `2048..=4094`);
207    /// markets listed after the September 2026 upgrade take the next free
208    /// index from `4095` for either product type.
209    pub market_index: i64,
210    /// Caller-supplied unique-per-account order index.
211    pub client_order_index: i64,
212    /// Order size in base-asset ticks.
213    pub base_amount: i64,
214    /// Limit / trigger price in quote-asset ticks.
215    pub price: u32,
216    /// `true` for a sell, `false` for a buy. Wire-encoded as `1` or `0`.
217    pub is_ask: bool,
218    /// Order type discriminant; see `LighterOrderType` for the mapping.
219    pub order_type: u8,
220    /// Time-in-force discriminant; see `LighterTimeInForce` for the mapping.
221    pub time_in_force: u8,
222    /// `true` for a reduce-only order. Wire-encoded as `1` or `0`.
223    pub reduce_only: bool,
224    /// Trigger price for stop / take-profit variants; `0` when unused.
225    pub trigger_price: u32,
226    /// Order expiry in milliseconds; `0` for IOC, `-1` for the venue default.
227    pub order_expiry: i64,
228}
229
230impl OrderInfo {
231    fn append_body_elements(&self, elems: &mut Vec<Fp>) {
232        elems.push(field_from_i64(self.market_index));
233        elems.push(field_from_i64(self.client_order_index));
234        elems.push(field_from_i64(self.base_amount));
235        elems.push(field_from_u32(self.price));
236        elems.push(field_from_u8(u8::from(self.is_ask)));
237        elems.push(field_from_u8(self.order_type));
238        elems.push(field_from_u8(self.time_in_force));
239        elems.push(field_from_u8(u8::from(self.reduce_only)));
240        elems.push(field_from_u32(self.trigger_price));
241        elems.push(field_from_i64(self.order_expiry));
242    }
243}
244
245/// Common context wrapping a tx body: account, key, nonce, expiry.
246///
247/// Lighter's hash preamble starts with `(chain_id, tx_type, nonce, expired_at)`
248/// followed by the body fields. The wrapper avoids repeating the preamble in
249/// every tx struct.
250#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
251pub struct TxContext {
252    /// L2 account index of the signer.
253    pub account_index: i64,
254    /// API key index registered for this account (`0..=254`).
255    pub api_key_index: u8,
256    /// Lighter L2 sequence number. Reset only via the sequencer's reset rules.
257    pub nonce: i64,
258    /// Tx expiry in milliseconds; rejected if the sequencer sees it past wall.
259    pub expired_at: i64,
260}
261
262/// Marker trait every signed L2 transaction implements.
263///
264/// The hash preimage is `[chain_id, tx_type, nonce, expired_at, account_index,
265/// api_key_index, ...body]`. Implementations are responsible for appending the
266/// per-type body elements after the preamble.
267pub trait LighterTx {
268    /// L2 transaction type discriminant emitted on the wire.
269    fn tx_type(&self) -> LighterTxType;
270
271    /// Common preamble fields.
272    fn context(&self) -> TxContext;
273
274    /// Per-transaction attributes; defaults to an empty set.
275    fn attributes(&self) -> L2TxAttributes {
276        L2TxAttributes::default()
277    }
278
279    /// Append the body field elements after the preamble. Caller pushes the
280    /// preamble; implementations push the body in the upstream-fixed order.
281    fn push_body_elements(&self, elems: &mut Vec<Fp>);
282
283    /// Build the full hash preimage `[preamble || body]` ready to feed to
284    /// `hash_to_quintic_extension`.
285    fn hash_elements(&self, chain_id: u32) -> Vec<Fp> {
286        let ctx = self.context();
287        let mut elems = Vec::with_capacity(16);
288        elems.push(field_from_u32(chain_id));
289        elems.push(field_from_u8(self.tx_type() as u8));
290        elems.push(field_from_i64(ctx.nonce));
291        elems.push(field_from_i64(ctx.expired_at));
292        elems.push(field_from_i64(ctx.account_index));
293        elems.push(field_from_u8(ctx.api_key_index));
294        self.push_body_elements(&mut elems);
295        elems
296    }
297}
298
299/// `CreateOrder` (`tx_type = 14`) - submits a new order to the sequencer.
300#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
301pub struct CreateOrderTxInfo {
302    /// Common preamble fields.
303    pub context: TxContext,
304    /// Order body.
305    pub order: OrderInfo,
306    /// L2 attributes.
307    pub attributes: L2TxAttributes,
308}
309
310impl LighterTx for CreateOrderTxInfo {
311    fn tx_type(&self) -> LighterTxType {
312        LighterTxType::CreateOrder
313    }
314
315    fn context(&self) -> TxContext {
316        self.context
317    }
318
319    fn attributes(&self) -> L2TxAttributes {
320        self.attributes
321    }
322
323    fn push_body_elements(&self, elems: &mut Vec<Fp>) {
324        self.order.append_body_elements(elems);
325    }
326}
327
328/// `CreateGroupedOrders` (`tx_type = 28`): creates native OTO, OCO, or OTOCO relationships.
329///
330/// Orders are hashed individually and compressed in submission order. OTO and OTOCO put
331/// the parent first and submit reduce-only children with zero base amount.
332#[derive(Clone, Debug, PartialEq, Eq, Hash)]
333pub struct CreateGroupedOrdersTxInfo {
334    /// Common preamble fields.
335    pub context: TxContext,
336    /// Native venue relationship.
337    pub grouping_type: LighterGroupingType,
338    /// Order bodies, with the parent first for OTO and OTOCO.
339    pub orders: Vec<OrderInfo>,
340    /// L2 attributes.
341    pub attributes: L2TxAttributes,
342}
343
344impl LighterTx for CreateGroupedOrdersTxInfo {
345    fn tx_type(&self) -> LighterTxType {
346        LighterTxType::CreateGroupedOrders
347    }
348
349    fn context(&self) -> TxContext {
350        self.context
351    }
352
353    fn attributes(&self) -> L2TxAttributes {
354        self.attributes
355    }
356
357    fn push_body_elements(&self, elems: &mut Vec<Fp>) {
358        elems.push(field_from_u8(self.grouping_type as u8));
359        let mut aggregate = [Fp::ZERO; HASH_OUT];
360
361        for (index, order) in self.orders.iter().enumerate() {
362            let mut body = Vec::with_capacity(10);
363            order.append_body_elements(&mut body);
364            let digest = hash_no_pad(&body);
365            aggregate = if index == 0 {
366                digest
367            } else {
368                hash_two_to_one(aggregate, digest)
369            };
370        }
371
372        elems.extend_from_slice(&aggregate);
373    }
374}
375
376/// `CancelOrder` (`tx_type = 15`): cancels a single live order by index.
377///
378/// `CancelOrder` only accepts the `skip_nonce` L2 attribute.
379#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
380pub struct CancelOrderTxInfo {
381    /// Common preamble fields.
382    pub context: TxContext,
383    /// Market the order lives on.
384    pub market_index: i64,
385    /// Either the venue's order index or the caller's client order index.
386    pub index: i64,
387    /// `1` to instruct the sequencer to skip nonce bookkeeping for this tx.
388    pub skip_nonce: u8,
389}
390
391impl LighterTx for CancelOrderTxInfo {
392    fn tx_type(&self) -> LighterTxType {
393        LighterTxType::CancelOrder
394    }
395
396    fn context(&self) -> TxContext {
397        self.context
398    }
399
400    fn attributes(&self) -> L2TxAttributes {
401        L2TxAttributes {
402            skip_nonce: self.skip_nonce,
403            ..Default::default()
404        }
405    }
406
407    fn push_body_elements(&self, elems: &mut Vec<Fp>) {
408        elems.push(field_from_i64(self.market_index));
409        elems.push(field_from_i64(self.index));
410    }
411}
412
413/// `ModifyOrder` (`tx_type = 17`) - amends size / price on a live order.
414#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
415pub struct ModifyOrderTxInfo {
416    /// Common preamble fields.
417    pub context: TxContext,
418    /// Market the order lives on.
419    pub market_index: i64,
420    /// Either the venue's order index or the caller's client order index.
421    pub index: i64,
422    /// Replacement size in base-asset ticks.
423    pub base_amount: i64,
424    /// Replacement limit price in quote-asset ticks.
425    pub price: u32,
426    /// Replacement trigger price (`0` to clear).
427    pub trigger_price: u32,
428    /// L2 attributes.
429    pub attributes: L2TxAttributes,
430}
431
432impl LighterTx for ModifyOrderTxInfo {
433    fn tx_type(&self) -> LighterTxType {
434        LighterTxType::ModifyOrder
435    }
436
437    fn context(&self) -> TxContext {
438        self.context
439    }
440
441    fn attributes(&self) -> L2TxAttributes {
442        self.attributes
443    }
444
445    fn push_body_elements(&self, elems: &mut Vec<Fp>) {
446        elems.push(field_from_i64(self.market_index));
447        elems.push(field_from_i64(self.index));
448        elems.push(field_from_i64(self.base_amount));
449        elems.push(field_from_u32(self.price));
450        elems.push(field_from_u32(self.trigger_price));
451    }
452}
453
454/// `ApproveIntegrator` (`tx_type = 45`): approves an integrator account index.
455///
456/// The upstream FFI wrapper for this tx kind passes only the `skip_nonce` L2
457/// attribute.
458#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
459pub struct ApproveIntegratorTxInfo {
460    /// Common preamble fields.
461    pub context: TxContext,
462    /// Integrator account index to approve.
463    pub integrator_account_index: i64,
464    /// Maximum perps taker fee the integrator may charge.
465    pub max_perps_taker_fee: u32,
466    /// Maximum perps maker fee the integrator may charge.
467    pub max_perps_maker_fee: u32,
468    /// Maximum spot taker fee the integrator may charge.
469    pub max_spot_taker_fee: u32,
470    /// Maximum spot maker fee the integrator may charge.
471    pub max_spot_maker_fee: u32,
472    /// Approval expiry in milliseconds; `0` revokes.
473    pub approval_expiry: i64,
474    /// `1` to instruct the sequencer to skip nonce bookkeeping for this tx.
475    pub skip_nonce: u8,
476}
477
478impl LighterTx for ApproveIntegratorTxInfo {
479    fn tx_type(&self) -> LighterTxType {
480        LighterTxType::ApproveIntegrator
481    }
482
483    fn context(&self) -> TxContext {
484        self.context
485    }
486
487    fn attributes(&self) -> L2TxAttributes {
488        L2TxAttributes {
489            skip_nonce: self.skip_nonce,
490            ..Default::default()
491        }
492    }
493
494    fn push_body_elements(&self, elems: &mut Vec<Fp>) {
495        elems.push(field_from_i64(self.integrator_account_index));
496        elems.push(field_from_u32(self.max_perps_taker_fee));
497        elems.push(field_from_u32(self.max_perps_maker_fee));
498        elems.push(field_from_u32(self.max_spot_taker_fee));
499        elems.push(field_from_u32(self.max_spot_maker_fee));
500        elems.push(field_from_i64(self.approval_expiry));
501    }
502}
503
504/// `CancelAllOrders` (`tx_type = 16`): account-wide cancel directive.
505///
506/// Body fields mirror the `SignCancelAllOrders(int cTimeInForce, long long
507/// int cTime, ...)` FFI signature in the upstream cgo header. `time_in_force`
508/// is one of [`crate::common::enums::LighterCancelAllTimeInForce`] (Immediate,
509/// Scheduled, AbortScheduled); `scheduled_time_ms` is the wall-clock instant
510/// the sequencer should fire the cancel at, in milliseconds (ignored for
511/// `Immediate`).
512///
513/// Like [`CancelOrderTxInfo`], the FFI wrapper for this kind passes only
514/// `skip_nonce`, so only that attribute is carried.
515#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
516pub struct CancelAllOrdersTxInfo {
517    /// Common preamble fields.
518    pub context: TxContext,
519    /// Cancel scheduling discriminant (`Immediate`, `Scheduled`, `AbortScheduled`).
520    pub time_in_force: u8,
521    /// Scheduled fire time in milliseconds. `0` for `Immediate`.
522    pub scheduled_time_ms: i64,
523    /// `1` to instruct the sequencer to skip nonce bookkeeping for this tx.
524    pub skip_nonce: u8,
525}
526
527impl LighterTx for CancelAllOrdersTxInfo {
528    fn tx_type(&self) -> LighterTxType {
529        LighterTxType::CancelAllOrders
530    }
531
532    fn context(&self) -> TxContext {
533        self.context
534    }
535
536    fn attributes(&self) -> L2TxAttributes {
537        L2TxAttributes {
538            skip_nonce: self.skip_nonce,
539            ..Default::default()
540        }
541    }
542
543    fn push_body_elements(&self, elems: &mut Vec<Fp>) {
544        elems.push(field_from_u8(self.time_in_force));
545        elems.push(field_from_i64(self.scheduled_time_ms));
546    }
547}
548
549/// `UpdateLeverage` (`tx_type = 20`): change leverage / margin mode for a market.
550///
551/// Body fields mirror the upstream `txtypes.L2UpdateLeverageTxInfo` Go
552/// struct: `MarketIndex` (64-bit venue allocation),
553/// `InitialMarginFraction uint16`, `MarginMode uint8`.
554/// `initial_margin_fraction` is in 1e-4 ticks
555/// (`500` = 5% initial margin = 20x leverage), capped at the venue's
556/// `MarginFractionTick = 10_000`. `margin_mode` is
557/// [`crate::common::enums::LighterPositionMarginMode`] (`Cross`, `Isolated`).
558///
559/// Like [`CancelAllOrdersTxInfo`], only `skip_nonce` is carried.
560#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
561pub struct UpdateLeverageTxInfo {
562    /// Common preamble fields.
563    pub context: TxContext,
564    /// Market the change applies to.
565    pub market_index: i64,
566    /// Initial margin fraction in 1e-4 ticks; `500` = 5% = 20x leverage.
567    pub initial_margin_fraction: u16,
568    /// Margin-mode discriminant (`Cross`, `Isolated`).
569    pub margin_mode: u8,
570    /// `1` to instruct the sequencer to skip nonce bookkeeping for this tx.
571    pub skip_nonce: u8,
572}
573
574impl LighterTx for UpdateLeverageTxInfo {
575    fn tx_type(&self) -> LighterTxType {
576        LighterTxType::UpdateLeverage
577    }
578
579    fn context(&self) -> TxContext {
580        self.context
581    }
582
583    fn attributes(&self) -> L2TxAttributes {
584        L2TxAttributes {
585            skip_nonce: self.skip_nonce,
586            ..Default::default()
587        }
588    }
589
590    fn push_body_elements(&self, elems: &mut Vec<Fp>) {
591        elems.push(field_from_i64(self.market_index));
592        elems.push(field_from_u16(self.initial_margin_fraction));
593        elems.push(field_from_u8(self.margin_mode));
594    }
595}
596
597#[inline]
598fn field_from_u8(v: u8) -> Fp {
599    Fp::from_u64_reduce(u64::from(v))
600}
601
602#[inline]
603fn field_from_u16(v: u16) -> Fp {
604    Fp::from_u64_reduce(u64::from(v))
605}
606
607#[inline]
608fn field_from_u32(v: u32) -> Fp {
609    Fp::from_u64_reduce(u64::from(v))
610}
611
612// Negative values reach the field via Go's `GoldilocksField(int64)` cast,
613// which reinterprets the bit pattern as `uint64`; reproduce that here so the
614// preimage stays byte-equal with what the upstream signer hashes for any
615// negative tx-info field (e.g. nil-flag sentinels like `OrderExpiry = -1`).
616#[inline]
617fn field_from_i64(v: i64) -> Fp {
618    Fp::from_u64_reduce(v as u64)
619}
620
621#[cfg(test)]
622mod tests {
623    use proptest::prelude::*;
624    use rstest::rstest;
625
626    use super::*;
627
628    fn ctx() -> TxContext {
629        TxContext {
630            account_index: 12_345,
631            api_key_index: 5,
632            nonce: 7,
633            expired_at: 1_777_804_395_089,
634        }
635    }
636
637    #[rstest]
638    fn create_order_preamble_then_body() {
639        let tx = CreateOrderTxInfo {
640            context: ctx(),
641            order: OrderInfo {
642                market_index: 0,
643                client_order_index: 123,
644                base_amount: 1_000,
645                price: 405_000,
646                is_ask: true,
647                order_type: 0,
648                time_in_force: 1,
649                reduce_only: false,
650                trigger_price: 0,
651                order_expiry: 1_735_689_600_000,
652            },
653            attributes: L2TxAttributes::default(),
654        };
655        let elems = tx.hash_elements(300);
656
657        // The full 16-element preimage is asserted explicitly so a body-order
658        // mutation fails this test independently of the oracle hash equality.
659        let expected = [
660            field_from_u32(300),               // chain_id
661            field_from_u8(14),                 // tx_type
662            field_from_i64(7),                 // nonce
663            field_from_i64(1_777_804_395_089), // expired_at
664            field_from_i64(12_345),            // account_index
665            field_from_u8(5),                  // api_key_index
666            field_from_i64(0),                 // market_index
667            field_from_i64(123),               // client_order_index
668            field_from_i64(1_000),             // base_amount
669            field_from_u32(405_000),           // price
670            field_from_u8(1),                  // is_ask (true -> 1)
671            field_from_u8(0),                  // order_type
672            field_from_u8(1),                  // time_in_force
673            field_from_u8(0),                  // reduce_only (false -> 0)
674            field_from_u32(0),                 // trigger_price
675            field_from_i64(1_735_689_600_000), // order_expiry
676        ];
677        assert_eq!(elems.as_slice(), expected.as_slice());
678    }
679
680    #[rstest]
681    fn create_order_preimage_encodes_widened_market_index() {
682        let tx = CreateOrderTxInfo {
683            context: ctx(),
684            order: OrderInfo {
685                market_index: 40_000,
686                client_order_index: 123,
687                base_amount: 1_000,
688                price: 405_000,
689                is_ask: true,
690                order_type: 0,
691                time_in_force: 1,
692                reduce_only: false,
693                trigger_price: 0,
694                order_expiry: 1_735_689_600_000,
695            },
696            attributes: L2TxAttributes::default(),
697        };
698
699        let elems = tx.hash_elements(300);
700
701        assert_eq!(elems.len(), 16);
702        assert_eq!(elems[6], Fp::from_u64_reduce(40_000));
703    }
704
705    #[rstest]
706    fn cancel_order_preamble_then_body() {
707        let tx = CancelOrderTxInfo {
708            context: ctx(),
709            market_index: 0,
710            index: 123,
711            skip_nonce: 0,
712        };
713        let elems = tx.hash_elements(300);
714
715        let expected = [
716            field_from_u32(300),               // chain_id
717            field_from_u8(15),                 // tx_type (CancelOrder)
718            field_from_i64(7),                 // nonce
719            field_from_i64(1_777_804_395_089), // expired_at
720            field_from_i64(12_345),            // account_index
721            field_from_u8(5),                  // api_key_index
722            field_from_i64(0),                 // market_index
723            field_from_i64(123),               // index
724        ];
725        assert_eq!(elems.as_slice(), expected.as_slice());
726    }
727
728    #[rstest]
729    fn modify_order_preamble_then_body() {
730        let tx = ModifyOrderTxInfo {
731            context: ctx(),
732            market_index: 0,
733            index: 123,
734            base_amount: 1_100,
735            price: 410_000,
736            trigger_price: 0,
737            attributes: L2TxAttributes::default(),
738        };
739        let elems = tx.hash_elements(300);
740
741        let expected = [
742            field_from_u32(300),               // chain_id
743            field_from_u8(17),                 // tx_type (ModifyOrder)
744            field_from_i64(7),                 // nonce
745            field_from_i64(1_777_804_395_089), // expired_at
746            field_from_i64(12_345),            // account_index
747            field_from_u8(5),                  // api_key_index
748            field_from_i64(0),                 // market_index
749            field_from_i64(123),               // index
750            field_from_i64(1_100),             // base_amount
751            field_from_u32(410_000),           // price
752            field_from_u32(0),                 // trigger_price
753        ];
754        assert_eq!(elems.as_slice(), expected.as_slice());
755    }
756
757    #[rstest]
758    fn approve_integrator_preamble_then_body() {
759        let tx = ApproveIntegratorTxInfo {
760            context: ctx(),
761            integrator_account_index: 723_813,
762            max_perps_taker_fee: 500,
763            max_perps_maker_fee: 200,
764            max_spot_taker_fee: 600,
765            max_spot_maker_fee: 300,
766            approval_expiry: 1_780_000_000_000,
767            skip_nonce: 0,
768        };
769        let elems = tx.hash_elements(300);
770
771        let expected = [
772            field_from_u32(300),               // chain_id
773            field_from_u8(45),                 // tx_type (ApproveIntegrator)
774            field_from_i64(7),                 // nonce
775            field_from_i64(1_777_804_395_089), // expired_at
776            field_from_i64(12_345),            // account_index
777            field_from_u8(5),                  // api_key_index
778            field_from_i64(723_813),           // integrator_account_index
779            field_from_u32(500),               // max_perps_taker_fee
780            field_from_u32(200),               // max_perps_maker_fee
781            field_from_u32(600),               // max_spot_taker_fee
782            field_from_u32(300),               // max_spot_maker_fee
783            field_from_i64(1_780_000_000_000), // approval_expiry
784        ];
785        assert_eq!(elems.as_slice(), expected.as_slice());
786    }
787
788    #[rstest]
789    #[case::immediate(0, 0)]
790    #[case::scheduled(1, 1_800_000_000_000)]
791    #[case::abort_scheduled(2, 0)]
792    fn cancel_all_orders_preamble_then_body(
793        #[case] time_in_force: u8,
794        #[case] scheduled_time_ms: i64,
795    ) {
796        let tx = CancelAllOrdersTxInfo {
797            context: ctx(),
798            time_in_force,
799            scheduled_time_ms,
800            skip_nonce: 0,
801        };
802        let elems = tx.hash_elements(300);
803
804        let expected = [
805            field_from_u32(300),               // chain_id
806            field_from_u8(16),                 // tx_type (CancelAllOrders)
807            field_from_i64(7),                 // nonce
808            field_from_i64(1_777_804_395_089), // expired_at
809            field_from_i64(12_345),            // account_index
810            field_from_u8(5),                  // api_key_index
811            field_from_u8(time_in_force),      // time_in_force
812            field_from_i64(scheduled_time_ms), // scheduled_time_ms
813        ];
814        assert_eq!(elems.as_slice(), expected.as_slice());
815    }
816
817    #[rstest]
818    #[case::cross(0)]
819    #[case::isolated(1)]
820    fn update_leverage_preamble_then_body(#[case] margin_mode: u8) {
821        let tx = UpdateLeverageTxInfo {
822            context: ctx(),
823            market_index: 3,
824            initial_margin_fraction: 500, // 5% = 20x
825            margin_mode,
826            skip_nonce: 0,
827        };
828        let elems = tx.hash_elements(300);
829
830        let expected = [
831            field_from_u32(300),               // chain_id
832            field_from_u8(20),                 // tx_type (UpdateLeverage)
833            field_from_i64(7),                 // nonce
834            field_from_i64(1_777_804_395_089), // expired_at
835            field_from_i64(12_345),            // account_index
836            field_from_u8(5),                  // api_key_index
837            field_from_i64(3),                 // market_index
838            field_from_u16(500),               // initial_margin_fraction
839            field_from_u8(margin_mode),        // margin_mode
840        ];
841        assert_eq!(elems.as_slice(), expected.as_slice());
842    }
843
844    #[rstest]
845    #[case::default(L2TxAttributes::default(), true)]
846    #[case::integrator_account_only(
847        L2TxAttributes { integrator_account_index: 1, ..Default::default() },
848        false,
849    )]
850    #[case::taker_fee_only(
851        L2TxAttributes { integrator_taker_fee: 1, ..Default::default() },
852        false,
853    )]
854    #[case::maker_fee_only(
855        L2TxAttributes { integrator_maker_fee: 1, ..Default::default() },
856        false,
857    )]
858    #[case::skip_nonce_only(
859        L2TxAttributes { skip_nonce: 1, ..Default::default() },
860        false,
861    )]
862    fn attributes_is_empty_truth_table(#[case] attrs: L2TxAttributes, #[case] expected: bool) {
863        assert_eq!(attrs.is_empty(), expected);
864    }
865
866    #[rstest]
867    #[case::all_empty(L2TxAttributes::default(), [(0, 0); NB_ATTRIBUTES_PER_TX])]
868    #[case::skip_only(
869        L2TxAttributes { skip_nonce: 1, ..Default::default() },
870        [(4, 1), (0, 0), (0, 0), (0, 0)],
871    )]
872    #[case::all_set(
873        L2TxAttributes {
874            integrator_account_index: 100,
875            integrator_taker_fee: 50,
876            integrator_maker_fee: 20,
877            skip_nonce: 1,
878            ..Default::default()
879        },
880        [(1, 100), (2, 50), (3, 20), (4, 1)],
881    )]
882    #[case::sparse_with_padding(
883        L2TxAttributes {
884            integrator_account_index: 723_813,
885            integrator_taker_fee: 0,
886            integrator_maker_fee: 100,
887            skip_nonce: 0,
888            ..Default::default()
889        },
890        [(1, 723_813), (3, 100), (0, 0), (0, 0)],
891    )]
892    #[case::account_and_skip_only(
893        L2TxAttributes {
894            integrator_account_index: 7,
895            integrator_maker_fee: 9,
896            ..Default::default()
897        },
898        [(1, 7), (3, 9), (0, 0), (0, 0)],
899    )]
900    fn normalized_pairs_truth_table(
901        #[case] attrs: L2TxAttributes,
902        #[case] expected: [(u8, u64); NB_ATTRIBUTES_PER_TX],
903    ) {
904        assert_eq!(attrs.normalized_pairs().unwrap(), expected);
905    }
906
907    #[rstest]
908    #[case::market_zero(Some(0), [(5, 0), (0, 0), (0, 0), (0, 0)], false)]
909    #[case::market_other(Some(4098), [(5, 4098), (0, 0), (0, 0), (0, 0)], false)]
910    #[case::market_nil(Some(255), [(0, 0); NB_ATTRIBUTES_PER_TX], true)]
911    #[case::market_absent(None, [(0, 0); NB_ATTRIBUTES_PER_TX], true)]
912    fn cancel_all_market_attribute_normalization(
913        #[case] index: Option<i64>,
914        #[case] expected: [(u8, u64); NB_ATTRIBUTES_PER_TX],
915        #[case] empty: bool,
916    ) {
917        let attrs = L2TxAttributes {
918            cancel_all_market_index: index,
919            ..Default::default()
920        };
921
922        assert_eq!(attrs.normalized_pairs().unwrap(), expected);
923        assert_eq!(attrs.is_empty(), empty);
924    }
925
926    #[rstest]
927    #[case(-1)]
928    #[case(1_i64 << 48)]
929    fn cancel_all_market_attribute_rejects_out_of_range(#[case] index: i64) {
930        let attrs = L2TxAttributes {
931            cancel_all_market_index: Some(index),
932            ..Default::default()
933        };
934
935        assert_eq!(
936            attrs.validate().unwrap_err().to_string(),
937            format!("invalid Lighter cancel-all market index {index}")
938        );
939    }
940
941    #[rstest]
942    #[case(
943        LighterSelfTradeBehaviorMode::ExpireMaker,
944        LighterSelfTradeEqualityMode::AccountIndex,
945        true
946    )]
947    #[case(
948        LighterSelfTradeBehaviorMode::ExpireMaker,
949        LighterSelfTradeEqualityMode::MasterAccountIndex,
950        true
951    )]
952    #[case(
953        LighterSelfTradeBehaviorMode::ExpireTaker,
954        LighterSelfTradeEqualityMode::AccountIndex,
955        true
956    )]
957    #[case(
958        LighterSelfTradeBehaviorMode::ExpireTaker,
959        LighterSelfTradeEqualityMode::MasterAccountIndex,
960        true
961    )]
962    #[case(
963        LighterSelfTradeBehaviorMode::CancelBoth,
964        LighterSelfTradeEqualityMode::AccountIndex,
965        true
966    )]
967    #[case(
968        LighterSelfTradeBehaviorMode::CancelBoth,
969        LighterSelfTradeEqualityMode::MasterAccountIndex,
970        true
971    )]
972    #[case(
973        LighterSelfTradeBehaviorMode::Reduce,
974        LighterSelfTradeEqualityMode::AccountIndex,
975        true
976    )]
977    #[case(
978        LighterSelfTradeBehaviorMode::Reduce,
979        LighterSelfTradeEqualityMode::MasterAccountIndex,
980        false
981    )]
982    fn self_trade_attribute_combinations(
983        #[case] behavior: LighterSelfTradeBehaviorMode,
984        #[case] equality: LighterSelfTradeEqualityMode,
985        #[case] valid: bool,
986    ) {
987        let attrs = L2TxAttributes {
988            integrator_account_index: 723_813,
989            self_trade_behavior_mode: Some(behavior),
990            self_trade_equality_mode: Some(equality),
991            ..Default::default()
992        };
993
994        assert_eq!(attrs.validate().is_ok(), valid);
995    }
996
997    #[rstest]
998    #[case::taker(17, 0)]
999    #[case::maker(0, 29)]
1000    fn self_trade_attribute_rejects_nonzero_fees(
1001        #[case] taker: u32,
1002        #[case] maker: u32,
1003        #[values(false, true)] equality_only: bool,
1004    ) {
1005        let attrs = L2TxAttributes {
1006            integrator_account_index: 723_813,
1007            integrator_taker_fee: taker,
1008            integrator_maker_fee: maker,
1009            self_trade_behavior_mode: (!equality_only)
1010                .then_some(LighterSelfTradeBehaviorMode::ExpireTaker),
1011            self_trade_equality_mode: equality_only
1012                .then_some(LighterSelfTradeEqualityMode::MasterAccountIndex),
1013            ..Default::default()
1014        };
1015
1016        assert_eq!(
1017            attrs.validate().unwrap_err().to_string(),
1018            "Lighter self-trade modes cannot combine with non-zero integrator fees"
1019        );
1020    }
1021
1022    #[rstest]
1023    fn attribute_capacity_counts_explicit_defaults() {
1024        let attrs = L2TxAttributes {
1025            integrator_account_index: 723_813,
1026            skip_nonce: 1,
1027            cancel_all_market_index: Some(255),
1028            self_trade_behavior_mode: Some(LighterSelfTradeBehaviorMode::ExpireMaker),
1029            self_trade_equality_mode: Some(LighterSelfTradeEqualityMode::AccountIndex),
1030            ..Default::default()
1031        };
1032
1033        assert_eq!(
1034            attrs.normalized_pairs().unwrap_err().to_string(),
1035            "Lighter permits at most 4 transaction attributes"
1036        );
1037    }
1038
1039    #[rstest]
1040    fn self_trade_normalization_preserves_attribute_order() {
1041        let attrs = L2TxAttributes {
1042            integrator_account_index: 723_813,
1043            skip_nonce: 1,
1044            self_trade_behavior_mode: Some(LighterSelfTradeBehaviorMode::CancelBoth),
1045            self_trade_equality_mode: Some(LighterSelfTradeEqualityMode::MasterAccountIndex),
1046            ..Default::default()
1047        };
1048
1049        assert_eq!(
1050            attrs.normalized_pairs().unwrap(),
1051            [(1, 723_813), (4, 1), (6, 2), (7, 1)]
1052        );
1053    }
1054
1055    #[rstest]
1056    fn negative_int_field_matches_go_cast() {
1057        // Go's `GoldilocksField(int64(-1))` reinterprets bits to `u64::MAX`;
1058        // our constructor goes through the same cast, then reduces.
1059        let from_neg_one = field_from_i64(-1);
1060        let from_u64_max = Fp::from_u64_reduce(u64::MAX);
1061        assert_eq!(from_neg_one, from_u64_max);
1062    }
1063
1064    proptest! {
1065        /// `field_from_i64` matches the bit-cast `as u64` lift through
1066        /// `Fp::from_u64_reduce` for any signed input.
1067        #[rstest]
1068        fn prop_field_from_i64_matches_u64_cast(v in any::<i64>()) {
1069            prop_assert_eq!(field_from_i64(v), Fp::from_u64_reduce(v as u64));
1070        }
1071    }
1072}