Skip to main content

nautilus_lighter/signing/tx/
encode.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//! Hash + sign pipeline for L2 transactions.
17//!
18//! Given any [`LighterTx`]:
19//!
20//! 1. Build the body field-element preimage (`[chain_id, tx_type, nonce,
21//!    expired_at, account_index, api_key_index, ...body]`) and Poseidon2-hash
22//!    it into a single `Fp5` digest.
23//! 2. If the per-tx [`L2TxAttributes`] are non-empty, hash the
24//!    `(type, value)` pair sequence into a second `Fp5` and Poseidon2 again
25//!    over `body_digest || attributes_digest`. Empty attributes short-circuit
26//!    to the body digest.
27//! 3. Encode the resulting `Fp5` to 40 canonical little-endian bytes; this is
28//!    the signed message hash and the venue's `tx_hash`.
29//! 4. Sign with the caller-supplied `(sk, k)` to produce 80 sig bytes.
30//! 5. Render the wire `tx_info` JSON with the same field order the upstream
31//!    Go signer marshals (Sig is base64).
32//!
33//! Step 2's aggregation order - `body || attributes` - and the ascending-type
34//! sort over attributes are both load-bearing for byte equality with the
35//! sequencer; both come straight from `txtypes.L2TxAttributes.AggregateTxHash`.
36
37use std::fmt::Write;
38
39use base64::{Engine, engine::general_purpose::STANDARD as B64};
40
41use super::types::{
42    ApproveIntegratorTxInfo, CancelAllOrdersTxInfo, CancelOrderTxInfo, CreateGroupedOrdersTxInfo,
43    CreateOrderTxInfo, L2TxAttributes, LighterTx, ModifyOrderTxInfo, NB_ATTRIBUTES_PER_TX,
44    OrderInfo, TxContext, UpdateLeverageTxInfo,
45};
46use crate::signing::{
47    field::{Fp, Fp5},
48    hash::{hash_to_quintic_extension, hash_two_to_quintic},
49    schnorr::{PrivateKey, SIG_BYTES, Signature},
50};
51
52/// Canonical wire length of a Lighter L2 message hash: 40-byte LE `Fp5`.
53pub const TX_HASH_BYTES: usize = 40;
54
55/// Compute the signed message hash for any [`LighterTx`].
56///
57/// Combines the body Poseidon2 hash with the attribute hash when attributes
58/// are populated; otherwise returns the body hash directly. The 40-byte LE
59/// encoding is the venue-side `tx_hash` and the message [`PrivateKey::sign`]
60/// consumes.
61#[must_use]
62pub fn compute_tx_hash<T: LighterTx>(tx: &T, chain_id: u32) -> [u8; TX_HASH_BYTES] {
63    compute_tx_hash_fp5(tx, chain_id).to_le_bytes()
64}
65
66fn compute_tx_hash_fp5<T: LighterTx>(tx: &T, chain_id: u32) -> Fp5 {
67    let body_elems = tx.hash_elements(chain_id);
68    let body_digest = hash_to_quintic_extension(&body_elems);
69
70    let attrs = tx.attributes();
71    if attrs.is_empty() {
72        return body_digest;
73    }
74
75    let attr_digest = hash_attributes(&attrs);
76    hash_two_to_quintic(body_digest, attr_digest)
77}
78
79/// Hash the attribute table into an `Fp5` digest.
80///
81/// Mirrors `txtypes.L2TxAttributes.Hash`: emit the normalized
82/// `(type, value)` pairs over [`NB_ATTRIBUTES_PER_TX`] slots, then run the
83/// length-2N preimage through [`hash_to_quintic_extension`].
84fn hash_attributes(attrs: &L2TxAttributes) -> Fp5 {
85    let pairs = attrs.normalized_pairs();
86    let mut elems = [Fp::ZERO; NB_ATTRIBUTES_PER_TX * 2];
87    for (i, (ty, val)) in pairs.iter().enumerate() {
88        elems[i * 2] = Fp::from_u64_reduce(u64::from(*ty));
89        elems[i * 2 + 1] = Fp::from_u64_reduce(*val);
90    }
91    hash_to_quintic_extension(&elems)
92}
93
94/// Sign any [`LighterTx`] under `(sk, k)` and return the 80-byte signature
95/// alongside the 40-byte tx hash.
96///
97/// `k` MUST be drawn from a cryptographic RNG and used at most once per key;
98/// see [`PrivateKey::sign`] for the full nonce contract. The wire signature is
99/// laid out as `s_le || e_le`.
100#[must_use]
101pub fn sign_tx<T: LighterTx>(
102    tx: &T,
103    chain_id: u32,
104    sk: &PrivateKey,
105    k: crate::signing::curve::Scalar,
106) -> SignedTx {
107    let hashed_msg = compute_tx_hash_fp5(tx, chain_id);
108    let sig = sk.sign(hashed_msg, k);
109    SignedTx {
110        tx_hash: hashed_msg.to_le_bytes(),
111        sig,
112        sig_bytes: sig.to_le_bytes(),
113    }
114}
115
116/// Outcome of [`sign_tx`]: the deterministic message hash plus the
117/// `(s, e)` Schnorr signature.
118#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
119pub struct SignedTx {
120    /// 40-byte LE message hash that was signed; matches the venue `tx_hash`.
121    pub tx_hash: [u8; TX_HASH_BYTES],
122    /// `(s, e)` Schnorr signature.
123    pub sig: Signature,
124    /// `s_le || e_le` 80-byte wire encoding of [`Self::sig`].
125    pub sig_bytes: [u8; SIG_BYTES],
126}
127
128impl SignedTx {
129    /// Lowercase hex rendering of [`Self::tx_hash`], the form the venue
130    /// echoes in sendTx responses.
131    #[must_use]
132    pub fn tx_hash_hex(&self) -> String {
133        let mut s = String::with_capacity(TX_HASH_BYTES * 2);
134        for b in &self.tx_hash {
135            write!(&mut s, "{b:02x}").expect("writing into String never fails");
136        }
137        s
138    }
139}
140
141/// JSON renderer for the L2 tx_info wire payload.
142///
143/// Field order and base64-encoded `Sig` match the upstream Go marshaling so
144/// the resulting string is byte-equivalent (modulo the random `Sig`) to what
145/// the closed signer emits, and is what the sequencer expects on `sendTx`.
146#[derive(Debug)]
147pub struct TxInfoJson;
148
149impl TxInfoJson {
150    /// Render a signed `UpdateLeverage` to its JSON payload.
151    ///
152    /// Wire field names mirror the upstream `txtypes.L2UpdateLeverageTxInfo`
153    /// Go struct. The FFI wrapper for this kind passes only `SkipNonce`, so
154    /// `L2TxAttributes` is `null` or a single `{"4":1}` entry.
155    #[must_use]
156    pub fn update_leverage(tx: &UpdateLeverageTxInfo, signed: &SignedTx) -> String {
157        let mut out = String::with_capacity(256);
158        out.push('{');
159        write_ctx_lead(&mut out, tx.context);
160        write_kv_i64(&mut out, "MarketIndex", tx.market_index);
161        write_kv_u64(
162            &mut out,
163            "InitialMarginFraction",
164            u64::from(tx.initial_margin_fraction),
165        ); // u16 widens to u64
166        write_kv_u64(&mut out, "MarginMode", u64::from(tx.margin_mode));
167        write_ctx_tail(&mut out, tx.context);
168        write_sig(&mut out, signed);
169        write_attributes_skip_nonce_only(&mut out, &tx.attributes());
170        out.push('}');
171        out
172    }
173
174    /// Render a signed `CreateOrder` to its JSON payload.
175    #[must_use]
176    pub fn create_order(tx: &CreateOrderTxInfo, signed: &SignedTx) -> String {
177        let mut out = String::with_capacity(384);
178        out.push('{');
179        write_ctx_lead(&mut out, tx.context);
180        write_order_info(&mut out, &tx.order);
181        write_ctx_tail(&mut out, tx.context);
182        write_sig(&mut out, signed);
183        write_attributes_with_integrator(&mut out, &tx.attributes);
184        out.push('}');
185        out
186    }
187
188    /// Render a signed `CreateGroupedOrders` to its JSON payload.
189    #[must_use]
190    pub fn create_grouped_orders(tx: &CreateGroupedOrdersTxInfo, signed: &SignedTx) -> String {
191        let mut out = String::with_capacity(256 + tx.orders.len() * 256);
192        out.push('{');
193        write_ctx_lead(&mut out, tx.context);
194        write_kv_u64(&mut out, "GroupingType", u64::from(tx.grouping_type as u8));
195        out.push_str("\"Orders\":[");
196
197        for (index, order) in tx.orders.iter().enumerate() {
198            if index != 0 {
199                out.push(',');
200            }
201
202            out.push('{');
203            write_order_info(&mut out, order);
204            out.pop();
205            out.push('}');
206        }
207
208        out.push_str("],");
209        write_ctx_tail(&mut out, tx.context);
210        write_sig(&mut out, signed);
211        write_attributes_with_integrator(&mut out, &tx.attributes);
212        out.push('}');
213        out
214    }
215
216    /// Render a signed `ModifyOrder` to its JSON payload.
217    #[must_use]
218    pub fn modify_order(tx: &ModifyOrderTxInfo, signed: &SignedTx) -> String {
219        let mut out = String::with_capacity(320);
220        out.push('{');
221        write_ctx_lead(&mut out, tx.context);
222        write_kv_i64(&mut out, "MarketIndex", tx.market_index);
223        write_kv_i64(&mut out, "Index", tx.index);
224        write_kv_i64(&mut out, "BaseAmount", tx.base_amount);
225        write_kv_u64(&mut out, "Price", u64::from(tx.price));
226        write_kv_u64(&mut out, "TriggerPrice", u64::from(tx.trigger_price));
227        write_ctx_tail(&mut out, tx.context);
228        write_sig(&mut out, signed);
229        write_attributes_with_integrator(&mut out, &tx.attributes);
230        out.push('}');
231        out
232    }
233
234    /// Render a signed `CancelOrder` to its JSON payload.
235    ///
236    /// `CancelOrder` only accepts the `skip_nonce` L2 attribute.
237    #[must_use]
238    pub fn cancel_order(tx: &CancelOrderTxInfo, signed: &SignedTx) -> String {
239        let mut out = String::with_capacity(256);
240        out.push('{');
241        write_ctx_lead(&mut out, tx.context);
242        write_kv_i64(&mut out, "MarketIndex", tx.market_index);
243        write_kv_i64(&mut out, "Index", tx.index);
244        write_ctx_tail(&mut out, tx.context);
245        write_sig(&mut out, signed);
246        write_attributes_skip_nonce_only(&mut out, &tx.attributes());
247        out.push('}');
248        out
249    }
250
251    /// Render a signed `CancelAllOrders` to its JSON payload.
252    ///
253    /// Wire field names mirror the upstream `txtypes.L2CancelAllOrdersTxInfo`
254    /// Go struct. The FFI wrapper for this kind passes only `SkipNonce`, so
255    /// `L2TxAttributes` is `null` or a single `{"4":1}` entry.
256    #[must_use]
257    pub fn cancel_all_orders(tx: &CancelAllOrdersTxInfo, signed: &SignedTx) -> String {
258        let mut out = String::with_capacity(256);
259        out.push('{');
260        write_ctx_lead(&mut out, tx.context);
261        write_kv_u64(&mut out, "TimeInForce", u64::from(tx.time_in_force));
262        write_kv_i64(&mut out, "Time", tx.scheduled_time_ms);
263        write_ctx_tail(&mut out, tx.context);
264        write_sig(&mut out, signed);
265        write_attributes_skip_nonce_only(&mut out, &tx.attributes());
266        out.push('}');
267        out
268    }
269
270    /// Render a signed `ApproveIntegrator` to its JSON payload.
271    ///
272    /// Pass an empty `l1_sig` when no L1 signature is present.
273    /// `L2TxAttributes` uses the same null-or-`skip_nonce` shape as `CancelOrder`.
274    #[must_use]
275    pub fn approve_integrator(
276        tx: &ApproveIntegratorTxInfo,
277        signed: &SignedTx,
278        l1_sig: &str,
279    ) -> String {
280        let mut out = String::with_capacity(384);
281        out.push('{');
282        write_ctx_lead(&mut out, tx.context);
283        write_kv_i64(
284            &mut out,
285            "IntegratorAccountIndex",
286            tx.integrator_account_index,
287        );
288        write_kv_u64(
289            &mut out,
290            "MaxPerpsTakerFee",
291            u64::from(tx.max_perps_taker_fee),
292        );
293        write_kv_u64(
294            &mut out,
295            "MaxPerpsMakerFee",
296            u64::from(tx.max_perps_maker_fee),
297        );
298        write_kv_u64(
299            &mut out,
300            "MaxSpotTakerFee",
301            u64::from(tx.max_spot_taker_fee),
302        );
303        write_kv_u64(
304            &mut out,
305            "MaxSpotMakerFee",
306            u64::from(tx.max_spot_maker_fee),
307        );
308        write_kv_i64(&mut out, "ApprovalExpiry", tx.approval_expiry);
309        write_ctx_tail(&mut out, tx.context);
310        write_sig(&mut out, signed);
311        out.push_str("\"L1Sig\":\"");
312        out.push_str(l1_sig);
313        out.push_str("\",");
314        write_attributes_skip_nonce_only(&mut out, &tx.attributes());
315        out.push('}');
316        out
317    }
318}
319
320fn write_ctx_lead(out: &mut String, ctx: TxContext) {
321    write_kv_i64(out, "AccountIndex", ctx.account_index);
322    write_kv_u64(out, "ApiKeyIndex", u64::from(ctx.api_key_index));
323}
324
325fn write_ctx_tail(out: &mut String, ctx: TxContext) {
326    write_kv_i64(out, "ExpiredAt", ctx.expired_at);
327    write_kv_i64(out, "Nonce", ctx.nonce);
328}
329
330fn write_order_info(out: &mut String, order: &OrderInfo) {
331    write_kv_i64(out, "MarketIndex", order.market_index);
332    write_kv_i64(out, "ClientOrderIndex", order.client_order_index);
333    write_kv_i64(out, "BaseAmount", order.base_amount);
334    write_kv_u64(out, "Price", u64::from(order.price));
335    write_kv_u64(out, "IsAsk", u64::from(u8::from(order.is_ask)));
336    write_kv_u64(out, "Type", u64::from(order.order_type));
337    write_kv_u64(out, "TimeInForce", u64::from(order.time_in_force));
338    write_kv_u64(out, "ReduceOnly", u64::from(u8::from(order.reduce_only)));
339    write_kv_u64(out, "TriggerPrice", u64::from(order.trigger_price));
340    write_kv_i64(out, "OrderExpiry", order.order_expiry);
341}
342
343fn write_sig(out: &mut String, signed: &SignedTx) {
344    out.push_str("\"Sig\":\"");
345    out.push_str(&B64.encode(signed.sig_bytes));
346    out.push_str("\",");
347}
348
349// Match upstream marshaling: nil-valued attributes are omitted, and a fully
350// empty Create/Modify attribute map is encoded as null.
351fn write_attributes_with_integrator(out: &mut String, attrs: &L2TxAttributes) {
352    if attrs.is_empty() {
353        out.push_str("\"L2TxAttributes\":null");
354        return;
355    }
356
357    out.push_str("\"L2TxAttributes\":{");
358    let mut first = true;
359    if attrs.integrator_account_index != 0 {
360        write_attr_pair(out, &mut first, "1", attrs.integrator_account_index);
361    }
362
363    if attrs.integrator_taker_fee != 0 {
364        write_attr_pair(out, &mut first, "2", u64::from(attrs.integrator_taker_fee));
365    }
366
367    if attrs.integrator_maker_fee != 0 {
368        write_attr_pair(out, &mut first, "3", u64::from(attrs.integrator_maker_fee));
369    }
370
371    if attrs.skip_nonce != 0 {
372        write_attr_pair(out, &mut first, "4", u64::from(attrs.skip_nonce));
373    }
374    out.push('}');
375}
376
377// Cancel/CancelAll/Withdraw/etc.: the FFI wrapper passes only `skip_nonce`,
378// so the marshaled value is `null` when nothing is set, otherwise a single
379// `{"4":1}` entry.
380fn write_attributes_skip_nonce_only(out: &mut String, attrs: &L2TxAttributes) {
381    if attrs.skip_nonce == 0 {
382        out.push_str("\"L2TxAttributes\":null");
383        return;
384    }
385    out.push_str("\"L2TxAttributes\":{\"4\":");
386    write_u64(out, u64::from(attrs.skip_nonce));
387    out.push('}');
388}
389
390fn write_attr_pair(out: &mut String, first: &mut bool, key: &str, value: u64) {
391    if !*first {
392        out.push(',');
393    }
394    *first = false;
395    out.push('"');
396    out.push_str(key);
397    out.push_str("\":");
398    write_u64(out, value);
399}
400
401fn write_kv_i64(out: &mut String, key: &str, value: i64) {
402    out.push('"');
403    out.push_str(key);
404    out.push_str("\":");
405    write_i64(out, value);
406    out.push(',');
407}
408
409fn write_kv_u64(out: &mut String, key: &str, value: u64) {
410    out.push('"');
411    out.push_str(key);
412    out.push_str("\":");
413    write_u64(out, value);
414    out.push(',');
415}
416
417fn write_i64(out: &mut String, value: i64) {
418    write!(out, "{value}").expect("writing into String never fails");
419}
420
421fn write_u64(out: &mut String, value: u64) {
422    write!(out, "{value}").expect("writing into String never fails");
423}
424
425#[cfg(test)]
426mod tests {
427    use proptest::prelude::*;
428    use rstest::rstest;
429    use serde::Deserialize;
430
431    use super::*;
432    use crate::signing::{
433        curve::{SCALAR_BYTES, Scalar},
434        field::Fp,
435        fixtures::{arb_scalar_nonzero, bytes_to_hex, decode_scalar_bytes, hex_to_bytes},
436        tx::types::{NB_ATTRIBUTES_PER_TX, OrderInfo, TxContext},
437    };
438
439    const ORACLE_JSON: &str = include_str!(concat!(
440        env!("CARGO_MANIFEST_DIR"),
441        "/test_data/signing_tx_oracle.json",
442    ));
443
444    #[derive(Debug, Deserialize)]
445    struct OracleFile {
446        vectors: Vec<OracleVector>,
447    }
448
449    #[derive(Debug, Deserialize)]
450    struct OracleVector {
451        kind: String,
452        chain_id: u32,
453        sk: String,
454        account_index: i64,
455        api_key_index: u8,
456        nonce: i64,
457        expired_at: i64,
458        fields: serde_json::Value,
459        tx_type: u8,
460        tx_info: String,
461        tx_hash: String,
462        sig: String,
463    }
464
465    fn ctx_for(v: &OracleVector) -> TxContext {
466        TxContext {
467            account_index: v.account_index,
468            api_key_index: v.api_key_index,
469            nonce: v.nonce,
470            expired_at: v.expired_at,
471        }
472    }
473
474    fn attrs_from(fields: &serde_json::Value) -> L2TxAttributes {
475        L2TxAttributes {
476            integrator_account_index: fields["integrator_account_index"].as_u64().unwrap_or(0),
477            integrator_taker_fee: fields["integrator_taker_fee"].as_u64().unwrap_or(0) as u32,
478            integrator_maker_fee: fields["integrator_maker_fee"].as_u64().unwrap_or(0) as u32,
479            skip_nonce: fields["skip_nonce"].as_u64().unwrap_or(0) as u8,
480        }
481    }
482
483    fn expect_create_order(v: &OracleVector) -> CreateOrderTxInfo {
484        let f = &v.fields;
485        CreateOrderTxInfo {
486            context: ctx_for(v),
487            order: OrderInfo {
488                market_index: f["market_index"].as_i64().unwrap(),
489                client_order_index: f["client_order_index"].as_i64().unwrap(),
490                base_amount: f["base_amount"].as_i64().unwrap(),
491                price: f["price"].as_u64().unwrap() as u32,
492                is_ask: f["is_ask"].as_bool().unwrap(),
493                order_type: f["order_type"].as_u64().unwrap() as u8,
494                time_in_force: f["time_in_force"].as_u64().unwrap() as u8,
495                reduce_only: f["reduce_only"].as_bool().unwrap(),
496                trigger_price: f["trigger_price"].as_u64().unwrap() as u32,
497                order_expiry: f["order_expiry"].as_i64().unwrap(),
498            },
499            attributes: attrs_from(f),
500        }
501    }
502
503    fn expect_cancel_order(v: &OracleVector) -> CancelOrderTxInfo {
504        let f = &v.fields;
505        CancelOrderTxInfo {
506            context: ctx_for(v),
507            market_index: f["market_index"].as_i64().unwrap(),
508            index: f["index"].as_i64().unwrap(),
509            skip_nonce: f["skip_nonce"].as_u64().unwrap_or(0) as u8,
510        }
511    }
512
513    fn expect_modify_order(v: &OracleVector) -> ModifyOrderTxInfo {
514        let f = &v.fields;
515        ModifyOrderTxInfo {
516            context: ctx_for(v),
517            market_index: f["market_index"].as_i64().unwrap(),
518            index: f["index"].as_i64().unwrap(),
519            base_amount: f["base_amount"].as_i64().unwrap(),
520            price: f["price"].as_u64().unwrap() as u32,
521            trigger_price: f["trigger_price"].as_u64().unwrap() as u32,
522            attributes: attrs_from(f),
523        }
524    }
525
526    fn expect_cancel_all_orders(v: &OracleVector) -> CancelAllOrdersTxInfo {
527        let f = &v.fields;
528        CancelAllOrdersTxInfo {
529            context: ctx_for(v),
530            time_in_force: f["time_in_force"].as_u64().unwrap() as u8,
531            scheduled_time_ms: f["scheduled_time_ms"].as_i64().unwrap(),
532            skip_nonce: f["skip_nonce"].as_u64().unwrap_or(0) as u8,
533        }
534    }
535
536    fn expect_update_leverage(v: &OracleVector) -> UpdateLeverageTxInfo {
537        let f = &v.fields;
538        UpdateLeverageTxInfo {
539            context: ctx_for(v),
540            market_index: f["market_index"].as_i64().unwrap(),
541            initial_margin_fraction: f["initial_margin_fraction"].as_u64().unwrap() as u16,
542            margin_mode: f["margin_mode"].as_u64().unwrap() as u8,
543            skip_nonce: f["skip_nonce"].as_u64().unwrap_or(0) as u8,
544        }
545    }
546
547    fn expect_approve_integrator(v: &OracleVector) -> ApproveIntegratorTxInfo {
548        let f = &v.fields;
549        ApproveIntegratorTxInfo {
550            context: ctx_for(v),
551            integrator_account_index: f["integrator_account_index"].as_i64().unwrap(),
552            max_perps_taker_fee: f["max_perps_taker_fee"].as_u64().unwrap() as u32,
553            max_perps_maker_fee: f["max_perps_maker_fee"].as_u64().unwrap() as u32,
554            max_spot_taker_fee: f["max_spot_taker_fee"].as_u64().unwrap() as u32,
555            max_spot_maker_fee: f["max_spot_maker_fee"].as_u64().unwrap() as u32,
556            approval_expiry: f["approval_expiry"].as_i64().unwrap(),
557            skip_nonce: f["skip_nonce"].as_u64().unwrap_or(0) as u8,
558        }
559    }
560
561    fn assert_hash_matches<T: LighterTx>(tx: &T, v: &OracleVector) {
562        let got = compute_tx_hash(tx, v.chain_id);
563        assert_eq!(
564            bytes_to_hex(&got),
565            v.tx_hash,
566            "{}: tx_hash diverged",
567            v.kind,
568        );
569    }
570
571    fn assert_oracle_sig_verifies<T: LighterTx>(tx: &T, v: &OracleVector) {
572        let sig_bytes = hex_to_bytes(&v.sig);
573        assert_eq!(sig_bytes.len(), SIG_BYTES);
574        let mut buf = [0u8; SIG_BYTES];
575        buf.copy_from_slice(&sig_bytes);
576        let sig = Signature::from_le_bytes_reduce(buf);
577
578        let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
579        let pk = sk.public_key();
580        let tx_hash = compute_tx_hash(tx, v.chain_id);
581        let hashed = Fp5::try_from_le_bytes(tx_hash).expect("oracle hash must be canonical");
582
583        assert!(
584            pk.verify(hashed, &sig),
585            "{}: oracle sig must verify against the recomputed hash",
586            v.kind,
587        );
588    }
589
590    fn assert_round_trip_sign<T: LighterTx>(tx: &T, v: &OracleVector) {
591        let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
592        // Pick a nonzero, fixture-derived `k` - any non-zero canonical scalar
593        // is valid. Guarding against `k == 0` and non-canonical limbs makes
594        // the assertion fail loudly on the test scaffold rather than producing
595        // an undefined signature if the XOR happens to land on a bad value.
596        let mut k_bytes = decode_scalar_bytes(&v.sk);
597        k_bytes[0] ^= 0x01;
598        let k = Scalar::from_le_bytes_reduce(k_bytes);
599        assert!(!k.is_zero(), "{}: derived k must be non-zero", v.kind);
600        assert!(k.is_canonical(), "{}: derived k must be canonical", v.kind,);
601
602        let signed = sign_tx(tx, v.chain_id, &sk, k);
603        assert_eq!(
604            bytes_to_hex(&signed.tx_hash),
605            v.tx_hash,
606            "{}: sign_tx tx_hash diverged",
607            v.kind,
608        );
609        assert_eq!(
610            signed.tx_hash_hex(),
611            v.tx_hash,
612            "{}: tx_hash_hex must render the venue's lowercase hex form",
613            v.kind,
614        );
615        let pk = sk.public_key();
616        let hashed = Fp5::try_from_le_bytes(signed.tx_hash).unwrap();
617        assert!(
618            pk.verify(hashed, &signed.sig),
619            "{}: round-trip sig must verify",
620            v.kind,
621        );
622    }
623
624    #[rstest]
625    fn oracle_tx_hash_matches_create_order() {
626        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
627        for v in suite.vectors.iter().filter(|v| v.kind == "create_order") {
628            assert_eq!(v.tx_type, 14);
629            let tx = expect_create_order(v);
630            assert_hash_matches(&tx, v);
631            assert_oracle_sig_verifies(&tx, v);
632            assert_round_trip_sign(&tx, v);
633        }
634    }
635
636    #[rstest]
637    #[case(1, false)]
638    #[case(1, true)]
639    #[case(2, false)]
640    #[case(2, true)]
641    #[case(3, false)]
642    #[case(3, true)]
643    fn grouped_orders_match_sdk_oracle(#[case] grouping: u8, #[case] attributes: bool) {
644        let suite: OracleFile = serde_json::from_str(include_str!(concat!(
645            env!("CARGO_MANIFEST_DIR"),
646            "/test_data/signing_grouped_tx_oracle.json",
647        )))
648        .unwrap();
649
650        let matching: Vec<_> = suite
651            .vectors
652            .iter()
653            .filter(|vector| {
654                vector.fields["grouping_type"].as_u64() == Some(u64::from(grouping))
655                    && vector.fields["skip_nonce"].as_u64() == Some(u64::from(attributes))
656            })
657            .collect();
658
659        assert_eq!(matching.len(), 1);
660        let vector = matching[0];
661
662        let mut tx = CreateGroupedOrdersTxInfo {
663            context: ctx_for(vector),
664            grouping_type: serde_json::from_value(vector.fields["grouping_type"].clone()).unwrap(),
665            orders: vector.fields["orders"]
666                .as_array()
667                .unwrap()
668                .iter()
669                .map(|body| OrderInfo {
670                    market_index: body["MarketIndex"].as_i64().unwrap(),
671                    client_order_index: body["ClientOrderIndex"].as_i64().unwrap(),
672                    base_amount: body["BaseAmount"].as_i64().unwrap(),
673                    price: body["Price"].as_u64().unwrap() as u32,
674                    is_ask: body["IsAsk"].as_u64().unwrap() == 1,
675                    order_type: body["Type"].as_u64().unwrap() as u8,
676                    time_in_force: body["TimeInForce"].as_u64().unwrap() as u8,
677                    reduce_only: body["ReduceOnly"].as_u64().unwrap() == 1,
678                    trigger_price: body["TriggerPrice"].as_u64().unwrap() as u32,
679                    order_expiry: body["OrderExpiry"].as_i64().unwrap(),
680                })
681                .collect(),
682            attributes: attrs_from(&vector.fields),
683        };
684
685        assert_eq!(vector.tx_type, 28);
686        assert_eq!(tx.orders.len(), if grouping == 3 { 3 } else { 2 });
687        assert_hash_matches(&tx, vector);
688        assert_oracle_sig_verifies(&tx, vector);
689        assert_round_trip_sign(&tx, vector);
690        let signed = sign_tx(
691            &tx,
692            vector.chain_id,
693            &PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&vector.sk)),
694            Scalar::ONE,
695        );
696        assert_eq!(
697            redact_sig(&TxInfoJson::create_grouped_orders(&tx, &signed)),
698            redact_sig(&vector.tx_info)
699        );
700        tx.orders.swap(0, 1);
701        assert_ne!(compute_tx_hash(&tx, vector.chain_id), signed.tx_hash);
702    }
703
704    #[rstest]
705    fn oracle_tx_hash_matches_cancel_order() {
706        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
707        for v in suite.vectors.iter().filter(|v| v.kind == "cancel_order") {
708            assert_eq!(v.tx_type, 15);
709            let tx = expect_cancel_order(v);
710            assert_hash_matches(&tx, v);
711            assert_oracle_sig_verifies(&tx, v);
712            assert_round_trip_sign(&tx, v);
713        }
714    }
715
716    #[rstest]
717    fn oracle_tx_hash_matches_modify_order() {
718        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
719        for v in suite.vectors.iter().filter(|v| v.kind == "modify_order") {
720            assert_eq!(v.tx_type, 17);
721            let tx = expect_modify_order(v);
722            assert_hash_matches(&tx, v);
723            assert_oracle_sig_verifies(&tx, v);
724            assert_round_trip_sign(&tx, v);
725        }
726    }
727
728    #[rstest]
729    fn oracle_tx_hash_matches_cancel_all_orders() {
730        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
731        for v in suite
732            .vectors
733            .iter()
734            .filter(|v| v.kind == "cancel_all_orders")
735        {
736            assert_eq!(v.tx_type, 16);
737            let tx = expect_cancel_all_orders(v);
738            assert_hash_matches(&tx, v);
739            assert_oracle_sig_verifies(&tx, v);
740            assert_round_trip_sign(&tx, v);
741        }
742    }
743
744    #[rstest]
745    fn oracle_tx_hash_matches_update_leverage() {
746        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
747        for v in suite.vectors.iter().filter(|v| v.kind == "update_leverage") {
748            assert_eq!(v.tx_type, 20);
749            let tx = expect_update_leverage(v);
750            assert_hash_matches(&tx, v);
751            assert_oracle_sig_verifies(&tx, v);
752            assert_round_trip_sign(&tx, v);
753        }
754    }
755
756    #[rstest]
757    fn oracle_tx_hash_matches_approve_integrator() {
758        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
759        for v in suite
760            .vectors
761            .iter()
762            .filter(|v| v.kind == "approve_integrator")
763        {
764            assert_eq!(v.tx_type, 45);
765            let tx = expect_approve_integrator(v);
766            assert_hash_matches(&tx, v);
767            assert_oracle_sig_verifies(&tx, v);
768            assert_round_trip_sign(&tx, v);
769        }
770    }
771
772    /// Replace the random-`k`-driven `Sig` block with a stable placeholder
773    /// so two JSON renderings of the same body can be compared byte-for-byte
774    /// regardless of which `k` produced them.
775    fn redact_sig(json: &str) -> String {
776        let start = json.find("\"Sig\":\"").expect("Sig key missing");
777        let after_open = start + "\"Sig\":\"".len();
778        let close = json[after_open..]
779            .find('"')
780            .map(|i| after_open + i)
781            .expect("Sig value not closed");
782        let mut out = String::with_capacity(json.len());
783        out.push_str(&json[..after_open]);
784        out.push_str("REDACTED");
785        out.push_str(&json[close..]);
786        out
787    }
788
789    fn signed_with_fixture_k(
790        v: &OracleVector,
791        sk: &PrivateKey,
792        signed_tx: impl Fn(Scalar) -> SignedTx,
793    ) -> SignedTx {
794        let _ = sk;
795        let mut k_bytes = decode_scalar_bytes(&v.sk);
796        k_bytes[0] ^= 0x01;
797        signed_tx(Scalar::from_le_bytes_reduce(k_bytes))
798    }
799
800    #[rstest]
801    fn create_order_json_byte_equals_oracle_modulo_sig() {
802        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
803        for v in suite.vectors.iter().filter(|v| v.kind == "create_order") {
804            let tx = expect_create_order(v);
805            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
806            let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
807            let json = TxInfoJson::create_order(&tx, &signed);
808            assert_eq!(
809                redact_sig(&json),
810                redact_sig(&v.tx_info),
811                "create_order tx_info diverged",
812            );
813        }
814    }
815
816    #[rstest]
817    #[case(2)]
818    #[case(3)]
819    #[case(4)]
820    #[case(5)]
821    fn oracle_covers_conditional_create_order_type(#[case] order_type: u64) {
822        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
823        assert!(suite.vectors.iter().any(|v| {
824            v.kind == "create_order" && v.fields["order_type"].as_u64() == Some(order_type)
825        }));
826    }
827
828    #[rstest]
829    #[case("create_order")]
830    #[case("modify_order")]
831    fn oracle_covers_production_integrator_attributes(#[case] kind: &str) {
832        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
833        assert!(suite.vectors.iter().any(|v| {
834            v.kind == kind
835                && v.fields["integrator_account_index"].as_u64() == Some(723_813)
836                && v.fields["integrator_taker_fee"].as_u64() == Some(0)
837                && v.fields["integrator_maker_fee"].as_u64() == Some(0)
838        }));
839    }
840
841    #[rstest]
842    fn cancel_order_json_emits_null_attributes_when_empty() {
843        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
844        for v in suite.vectors.iter().filter(|v| v.kind == "cancel_order") {
845            let tx = expect_cancel_order(v);
846            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
847            let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
848            let json = TxInfoJson::cancel_order(&tx, &signed);
849            assert_eq!(
850                redact_sig(&json),
851                redact_sig(&v.tx_info),
852                "cancel_order tx_info diverged",
853            );
854        }
855    }
856
857    #[rstest]
858    fn modify_order_json_byte_equals_oracle_modulo_sig() {
859        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
860        for v in suite.vectors.iter().filter(|v| v.kind == "modify_order") {
861            let tx = expect_modify_order(v);
862            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
863            let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
864            let json = TxInfoJson::modify_order(&tx, &signed);
865            assert_eq!(
866                redact_sig(&json),
867                redact_sig(&v.tx_info),
868                "modify_order tx_info diverged",
869            );
870        }
871    }
872
873    fn stub_signed() -> SignedTx {
874        SignedTx {
875            tx_hash: [0u8; TX_HASH_BYTES],
876            sig: Signature {
877                s: Scalar::from_le_bytes_reduce([0u8; SCALAR_BYTES]),
878                e: Scalar::from_le_bytes_reduce([0u8; SCALAR_BYTES]),
879            },
880            sig_bytes: [0u8; SIG_BYTES],
881        }
882    }
883
884    fn stub_context() -> TxContext {
885        TxContext {
886            account_index: 12_345,
887            api_key_index: 5,
888            nonce: 7,
889            expired_at: 1_777_804_395_089,
890        }
891    }
892
893    #[rstest]
894    fn cancel_all_orders_json_byte_equals_oracle_modulo_sig() {
895        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
896        for v in suite
897            .vectors
898            .iter()
899            .filter(|v| v.kind == "cancel_all_orders")
900        {
901            let tx = expect_cancel_all_orders(v);
902            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
903            let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
904            let json = TxInfoJson::cancel_all_orders(&tx, &signed);
905            assert_eq!(
906                redact_sig(&json),
907                redact_sig(&v.tx_info),
908                "cancel_all_orders tx_info diverged",
909            );
910        }
911    }
912
913    #[rstest]
914    fn tx_info_json_renders_widened_market_index_as_number() {
915        let order = OrderInfo {
916            market_index: 40_000,
917            client_order_index: 123,
918            base_amount: 1_000,
919            price: 405_000,
920            is_ask: true,
921            order_type: 0,
922            time_in_force: 1,
923            reduce_only: false,
924            trigger_price: 0,
925            order_expiry: 1_735_689_600_000,
926        };
927
928        let create = CreateOrderTxInfo {
929            context: stub_context(),
930            order,
931            attributes: L2TxAttributes::default(),
932        };
933
934        let cancel = CancelOrderTxInfo {
935            context: stub_context(),
936            market_index: 40_000,
937            index: 123,
938            skip_nonce: 0,
939        };
940
941        let modify = ModifyOrderTxInfo {
942            context: stub_context(),
943            market_index: 40_000,
944            index: 123,
945            base_amount: 1_100,
946            price: 410_000,
947            trigger_price: 0,
948            attributes: L2TxAttributes::default(),
949        };
950
951        for json in [
952            TxInfoJson::create_order(&create, &stub_signed()),
953            TxInfoJson::cancel_order(&cancel, &stub_signed()),
954            TxInfoJson::modify_order(&modify, &stub_signed()),
955        ] {
956            assert!(
957                json.contains(r#""MarketIndex":40000"#),
958                "widened MarketIndex must render unquoted, was {json}",
959            );
960        }
961    }
962
963    #[rstest]
964    fn cancel_all_orders_json_emits_skip_nonce_attr_when_set() {
965        let tx = CancelAllOrdersTxInfo {
966            context: stub_context(),
967            time_in_force: 1, // Scheduled
968            scheduled_time_ms: 1_800_000_000_000,
969            skip_nonce: 1,
970        };
971        let json = TxInfoJson::cancel_all_orders(&tx, &stub_signed());
972        let expected = concat!(
973            r#"{"AccountIndex":12345,"ApiKeyIndex":5,"#,
974            r#""TimeInForce":1,"Time":1800000000000,"#,
975            r#""ExpiredAt":1777804395089,"Nonce":7,"#,
976            r#""Sig":"REDACTED","L2TxAttributes":{"4":1}}"#,
977        );
978        assert_eq!(redact_sig(&json), expected);
979    }
980
981    #[rstest]
982    fn update_leverage_json_byte_equals_oracle_modulo_sig() {
983        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
984        for v in suite.vectors.iter().filter(|v| v.kind == "update_leverage") {
985            let tx = expect_update_leverage(v);
986            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
987            let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
988            let json = TxInfoJson::update_leverage(&tx, &signed);
989            assert_eq!(
990                redact_sig(&json),
991                redact_sig(&v.tx_info),
992                "update_leverage tx_info diverged",
993            );
994        }
995    }
996
997    #[rstest]
998    fn approve_integrator_json_byte_equals_oracle_modulo_sig() {
999        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
1000        for v in suite
1001            .vectors
1002            .iter()
1003            .filter(|v| v.kind == "approve_integrator")
1004        {
1005            let tx = expect_approve_integrator(v);
1006            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
1007            let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
1008            let json = TxInfoJson::approve_integrator(&tx, &signed, "");
1009            assert_eq!(
1010                redact_sig(&json),
1011                redact_sig(&v.tx_info),
1012                "approve_integrator tx_info diverged",
1013            );
1014        }
1015    }
1016
1017    fn arb_tx_context() -> impl Strategy<Value = TxContext> {
1018        (any::<i64>(), any::<u8>(), any::<i64>(), any::<i64>()).prop_map(
1019            |(account_index, api_key_index, nonce, expired_at)| TxContext {
1020                account_index,
1021                api_key_index,
1022                nonce,
1023                expired_at,
1024            },
1025        )
1026    }
1027
1028    fn arb_order_info() -> impl Strategy<Value = OrderInfo> {
1029        (
1030            any::<i64>(),
1031            any::<i64>(),
1032            any::<i64>(),
1033            any::<u32>(),
1034            any::<bool>(),
1035            any::<u8>(),
1036            any::<u8>(),
1037            any::<bool>(),
1038            any::<u32>(),
1039            any::<i64>(),
1040        )
1041            .prop_map(
1042                |(
1043                    market_index,
1044                    client_order_index,
1045                    base_amount,
1046                    price,
1047                    is_ask,
1048                    order_type,
1049                    time_in_force,
1050                    reduce_only,
1051                    trigger_price,
1052                    order_expiry,
1053                )| OrderInfo {
1054                    market_index,
1055                    client_order_index,
1056                    base_amount,
1057                    price,
1058                    is_ask,
1059                    order_type,
1060                    time_in_force,
1061                    reduce_only,
1062                    trigger_price,
1063                    order_expiry,
1064                },
1065            )
1066    }
1067
1068    fn arb_l2_attributes() -> impl Strategy<Value = L2TxAttributes> {
1069        (any::<u64>(), any::<u32>(), any::<u32>(), any::<u8>()).prop_map(
1070            |(integrator_account_index, integrator_taker_fee, integrator_maker_fee, skip_nonce)| {
1071                L2TxAttributes {
1072                    integrator_account_index,
1073                    integrator_taker_fee,
1074                    integrator_maker_fee,
1075                    skip_nonce,
1076                }
1077            },
1078        )
1079    }
1080
1081    fn arb_create_order() -> impl Strategy<Value = CreateOrderTxInfo> {
1082        (arb_tx_context(), arb_order_info(), arb_l2_attributes()).prop_map(
1083            |(context, order, attributes)| CreateOrderTxInfo {
1084                context,
1085                order,
1086                attributes,
1087            },
1088        )
1089    }
1090
1091    proptest! {
1092        /// `compute_tx_hash` is deterministic over identical input.
1093        #[rstest]
1094        fn prop_compute_tx_hash_deterministic(tx in arb_create_order(), chain_id in any::<u32>()) {
1095            prop_assert_eq!(compute_tx_hash(&tx, chain_id), compute_tx_hash(&tx, chain_id));
1096        }
1097
1098        /// Empty attributes short-circuit through the body-only branch:
1099        /// `compute_tx_hash` matches `hash_to_quintic_extension(body_elems)`
1100        /// directly when `attributes.is_empty()`.
1101        #[rstest]
1102        fn prop_empty_attrs_branch_uses_body_hash_only(
1103            mut tx in arb_create_order(),
1104            chain_id in any::<u32>(),
1105        ) {
1106            tx.attributes = L2TxAttributes::default();
1107            let from_pipeline = compute_tx_hash(&tx, chain_id);
1108            let from_body =
1109                hash_to_quintic_extension(&tx.hash_elements(chain_id)).to_le_bytes();
1110            prop_assert_eq!(from_pipeline, from_body);
1111        }
1112
1113        /// Non-empty attributes engage the aggregation branch: the pipeline
1114        /// hash matches `hash_two_to_quintic(body_digest, attr_digest)`
1115        /// computed explicitly from the preimage. Pins the branch and the
1116        /// aggregation formula deterministically (no collision-resistance
1117        /// assumption).
1118        #[rstest]
1119        fn prop_non_empty_attrs_matches_aggregation_formula(
1120            mut tx in arb_create_order(),
1121            chain_id in any::<u32>(),
1122        ) {
1123            // Force at least one attribute slot populated so the aggregation
1124            // branch fires.
1125            tx.attributes = L2TxAttributes {
1126                integrator_account_index: 0,
1127                integrator_taker_fee: 0,
1128                integrator_maker_fee: 0,
1129                skip_nonce: 1,
1130            };
1131            let from_pipeline = compute_tx_hash(&tx, chain_id);
1132            let expected = explicit_tx_hash(&tx, chain_id);
1133            prop_assert_eq!(from_pipeline, expected);
1134        }
1135    }
1136
1137    /// Recompute the tx hash explicitly from `hash_elements` and the
1138    /// normalized attribute pairs, mirroring the two branches inside
1139    /// `compute_tx_hash_fp5`. Used by the proptests below to assert the
1140    /// pipeline hash equals the explicit branch formula without making a
1141    /// collision-resistance assumption.
1142    fn explicit_tx_hash<T: LighterTx>(tx: &T, chain_id: u32) -> [u8; TX_HASH_BYTES] {
1143        let body_elems = tx.hash_elements(chain_id);
1144        let body_digest = hash_to_quintic_extension(&body_elems);
1145        let attrs = tx.attributes();
1146        let result = if attrs.is_empty() {
1147            body_digest
1148        } else {
1149            let pairs = attrs.normalized_pairs();
1150            let mut elems = [Fp::from_u64_reduce(0); NB_ATTRIBUTES_PER_TX * 2];
1151            for (i, (ty, val)) in pairs.iter().enumerate() {
1152                elems[i * 2] = Fp::from_u64_reduce(u64::from(*ty));
1153                elems[i * 2 + 1] = Fp::from_u64_reduce(*val);
1154            }
1155            let attr_digest = hash_to_quintic_extension(&elems);
1156            hash_two_to_quintic(body_digest, attr_digest)
1157        };
1158        result.to_le_bytes()
1159    }
1160
1161    /// Mutator selector for `prop_field_change_changes_hash`. Each variant
1162    /// names a body, attribute, or context field that participates in the
1163    /// signed hash; mutating it MUST change the hash.
1164    #[derive(Debug, Clone, Copy)]
1165    enum CreateOrderField {
1166        ChainId,
1167        AccountIndex,
1168        ApiKeyIndex,
1169        Nonce,
1170        ExpiredAt,
1171        MarketIndex,
1172        ClientOrderIndex,
1173        BaseAmount,
1174        Price,
1175        IsAsk,
1176        OrderType,
1177        TimeInForce,
1178        ReduceOnly,
1179        TriggerPrice,
1180        OrderExpiry,
1181        IntegratorAccountIndex,
1182        IntegratorTakerFee,
1183        IntegratorMakerFee,
1184        SkipNonce,
1185    }
1186
1187    fn arb_create_order_field() -> impl Strategy<Value = CreateOrderField> {
1188        prop_oneof![
1189            Just(CreateOrderField::ChainId),
1190            Just(CreateOrderField::AccountIndex),
1191            Just(CreateOrderField::ApiKeyIndex),
1192            Just(CreateOrderField::Nonce),
1193            Just(CreateOrderField::ExpiredAt),
1194            Just(CreateOrderField::MarketIndex),
1195            Just(CreateOrderField::ClientOrderIndex),
1196            Just(CreateOrderField::BaseAmount),
1197            Just(CreateOrderField::Price),
1198            Just(CreateOrderField::IsAsk),
1199            Just(CreateOrderField::OrderType),
1200            Just(CreateOrderField::TimeInForce),
1201            Just(CreateOrderField::ReduceOnly),
1202            Just(CreateOrderField::TriggerPrice),
1203            Just(CreateOrderField::OrderExpiry),
1204            Just(CreateOrderField::IntegratorAccountIndex),
1205            Just(CreateOrderField::IntegratorTakerFee),
1206            Just(CreateOrderField::IntegratorMakerFee),
1207            Just(CreateOrderField::SkipNonce),
1208        ]
1209    }
1210
1211    /// Apply a `+ 1` (or `!` for booleans) mutation to the named field.
1212    /// `chain_id` is mutated in-place via the second tuple element; all
1213    /// other fields are mutated on the returned `CreateOrderTxInfo`.
1214    fn mutate_create_order_field(
1215        base: CreateOrderTxInfo,
1216        chain_id: u32,
1217        field: CreateOrderField,
1218    ) -> (CreateOrderTxInfo, u32) {
1219        let mut alt = base;
1220        let mut chain = chain_id;
1221        match field {
1222            CreateOrderField::ChainId => chain = chain.wrapping_add(1),
1223            CreateOrderField::AccountIndex => {
1224                alt.context.account_index = alt.context.account_index.wrapping_add(1);
1225            }
1226            CreateOrderField::ApiKeyIndex => {
1227                alt.context.api_key_index = alt.context.api_key_index.wrapping_add(1);
1228            }
1229            CreateOrderField::Nonce => alt.context.nonce = alt.context.nonce.wrapping_add(1),
1230            CreateOrderField::ExpiredAt => {
1231                alt.context.expired_at = alt.context.expired_at.wrapping_add(1);
1232            }
1233            CreateOrderField::MarketIndex => {
1234                alt.order.market_index = alt.order.market_index.wrapping_add(1);
1235            }
1236            CreateOrderField::ClientOrderIndex => {
1237                alt.order.client_order_index = alt.order.client_order_index.wrapping_add(1);
1238            }
1239            CreateOrderField::BaseAmount => {
1240                alt.order.base_amount = alt.order.base_amount.wrapping_add(1);
1241            }
1242            CreateOrderField::Price => alt.order.price = alt.order.price.wrapping_add(1),
1243            CreateOrderField::IsAsk => alt.order.is_ask = !alt.order.is_ask,
1244            CreateOrderField::OrderType => {
1245                alt.order.order_type = alt.order.order_type.wrapping_add(1);
1246            }
1247            CreateOrderField::TimeInForce => {
1248                alt.order.time_in_force = alt.order.time_in_force.wrapping_add(1);
1249            }
1250            CreateOrderField::ReduceOnly => alt.order.reduce_only = !alt.order.reduce_only,
1251            CreateOrderField::TriggerPrice => {
1252                alt.order.trigger_price = alt.order.trigger_price.wrapping_add(1);
1253            }
1254            CreateOrderField::OrderExpiry => {
1255                alt.order.order_expiry = alt.order.order_expiry.wrapping_add(1);
1256            }
1257            CreateOrderField::IntegratorAccountIndex => {
1258                alt.attributes.integrator_account_index =
1259                    alt.attributes.integrator_account_index.wrapping_add(1);
1260            }
1261            CreateOrderField::IntegratorTakerFee => {
1262                alt.attributes.integrator_taker_fee =
1263                    alt.attributes.integrator_taker_fee.wrapping_add(1);
1264            }
1265            CreateOrderField::IntegratorMakerFee => {
1266                alt.attributes.integrator_maker_fee =
1267                    alt.attributes.integrator_maker_fee.wrapping_add(1);
1268            }
1269            CreateOrderField::SkipNonce => {
1270                alt.attributes.skip_nonce = alt.attributes.skip_nonce.wrapping_add(1);
1271            }
1272        }
1273        (alt, chain)
1274    }
1275
1276    proptest! {
1277        /// Mutating any single body, attribute, or context field changes the
1278        /// signed *preimage* (`hash_elements` plus `attributes()` for
1279        /// attribute fields). Pins body-element ordering and attribute-slot
1280        /// participation deterministically - a regression that drops or
1281        /// swaps a field makes at least one mutation a no-op on the
1282        /// preimage. Asserting on the preimage rather than the digest
1283        /// avoids the hash-collision overreach (Poseidon compresses to
1284        /// ~320 bits, so universal-distinctness on digests is not a
1285        /// primitive contract).
1286        #[rstest]
1287        fn prop_field_change_changes_preimage(
1288            base in arb_create_order(),
1289            chain_id in any::<u32>(),
1290            field in arb_create_order_field(),
1291        ) {
1292            let (alt, chain) = mutate_create_order_field(base, chain_id, field);
1293            let base_preimage = (base.hash_elements(chain_id), base.attributes());
1294            let alt_preimage = (alt.hash_elements(chain), alt.attributes());
1295            prop_assert_ne!(
1296                base_preimage,
1297                alt_preimage,
1298                "mutation {:?} did not change preimage",
1299                field,
1300            );
1301        }
1302
1303        /// `compute_tx_hash` matches the explicit branch formula derived
1304        /// from `hash_elements` and `normalized_pairs`. Pins both branches
1305        /// (empty / non-empty attributes) and the branch selector
1306        /// (`L2TxAttributes::is_empty`) deterministically.
1307        #[rstest]
1308        fn prop_compute_tx_hash_matches_explicit_formula(
1309            tx in arb_create_order(),
1310            chain_id in any::<u32>(),
1311        ) {
1312            prop_assert_eq!(compute_tx_hash(&tx, chain_id), explicit_tx_hash(&tx, chain_id));
1313        }
1314
1315        /// `sign_tx` followed by `verify` succeeds for any well-formed
1316        /// CreateOrder body and any non-zero canonical nonce / secret key.
1317        #[rstest]
1318        fn prop_sign_then_verify_for_arbitrary_tx(
1319            tx in arb_create_order(),
1320            chain_id in any::<u32>(),
1321            sk in arb_scalar_nonzero(),
1322            k in arb_scalar_nonzero(),
1323        ) {
1324            let private_key = PrivateKey::from_scalar(sk);
1325            let pk = private_key.public_key();
1326            let signed = sign_tx(&tx, chain_id, &private_key, k);
1327
1328            // Recomputing the hash through `compute_tx_hash` must match
1329            // the value returned by `sign_tx`.
1330            prop_assert_eq!(signed.tx_hash, compute_tx_hash(&tx, chain_id));
1331
1332            let hashed = Fp5::try_from_le_bytes(signed.tx_hash)
1333                .expect("tx hash must encode a canonical Fp5");
1334            prop_assert!(pk.verify(hashed, &signed.sig));
1335        }
1336    }
1337
1338    #[rstest]
1339    fn cancel_order_json_emits_skip_nonce_only_attribute() {
1340        // Synthesized case: skip_nonce=1, no integrator slots
1341        let tx = CancelOrderTxInfo {
1342            context: TxContext {
1343                account_index: 1,
1344                api_key_index: 0,
1345                nonce: 0,
1346                expired_at: 0,
1347            },
1348            market_index: 0,
1349            index: 1,
1350            skip_nonce: 1,
1351        };
1352        let sk = PrivateKey::from_le_bytes_reduce([0x42; SCALAR_BYTES]);
1353        let signed = sign_tx(&tx, 300, &sk, Scalar::ONE);
1354        let json = TxInfoJson::cancel_order(&tx, &signed);
1355        assert!(
1356            json.ends_with(",\"L2TxAttributes\":{\"4\":1}}"),
1357            "was {json}"
1358        );
1359    }
1360}