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, CreateOrderTxInfo,
43    L2TxAttributes, LighterTx, ModifyOrderTxInfo, NB_ATTRIBUTES_PER_TX, OrderInfo, TxContext,
44    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 normalised
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 marshalling 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", i64::from(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 `ModifyOrder` to its JSON payload.
189    #[must_use]
190    pub fn modify_order(tx: &ModifyOrderTxInfo, signed: &SignedTx) -> String {
191        let mut out = String::with_capacity(320);
192        out.push('{');
193        write_ctx_lead(&mut out, tx.context);
194        write_kv_i64(&mut out, "MarketIndex", i64::from(tx.market_index));
195        write_kv_i64(&mut out, "Index", tx.index);
196        write_kv_i64(&mut out, "BaseAmount", tx.base_amount);
197        write_kv_u64(&mut out, "Price", u64::from(tx.price));
198        write_kv_u64(&mut out, "TriggerPrice", u64::from(tx.trigger_price));
199        write_ctx_tail(&mut out, tx.context);
200        write_sig(&mut out, signed);
201        write_attributes_with_integrator(&mut out, &tx.attributes);
202        out.push('}');
203        out
204    }
205
206    /// Render a signed `CancelOrder` to its JSON payload.
207    ///
208    /// `CancelOrder` only accepts the `skip_nonce` L2 attribute.
209    #[must_use]
210    pub fn cancel_order(tx: &CancelOrderTxInfo, signed: &SignedTx) -> String {
211        let mut out = String::with_capacity(256);
212        out.push('{');
213        write_ctx_lead(&mut out, tx.context);
214        write_kv_i64(&mut out, "MarketIndex", i64::from(tx.market_index));
215        write_kv_i64(&mut out, "Index", tx.index);
216        write_ctx_tail(&mut out, tx.context);
217        write_sig(&mut out, signed);
218        write_attributes_skip_nonce_only(&mut out, &tx.attributes());
219        out.push('}');
220        out
221    }
222
223    /// Render a signed `CancelAllOrders` to its JSON payload.
224    ///
225    /// Wire field names mirror the upstream `txtypes.L2CancelAllOrdersTxInfo`
226    /// Go struct. The FFI wrapper for this kind passes only `SkipNonce`, so
227    /// `L2TxAttributes` is `null` or a single `{"4":1}` entry.
228    #[must_use]
229    pub fn cancel_all_orders(tx: &CancelAllOrdersTxInfo, signed: &SignedTx) -> String {
230        let mut out = String::with_capacity(256);
231        out.push('{');
232        write_ctx_lead(&mut out, tx.context);
233        write_kv_u64(&mut out, "TimeInForce", u64::from(tx.time_in_force));
234        write_kv_i64(&mut out, "Time", tx.scheduled_time_ms);
235        write_ctx_tail(&mut out, tx.context);
236        write_sig(&mut out, signed);
237        write_attributes_skip_nonce_only(&mut out, &tx.attributes());
238        out.push('}');
239        out
240    }
241
242    /// Render a signed `ApproveIntegrator` to its JSON payload.
243    ///
244    /// Pass an empty `l1_sig` when no L1 signature is present.
245    /// `L2TxAttributes` uses the same null-or-`skip_nonce` shape as `CancelOrder`.
246    #[must_use]
247    pub fn approve_integrator(
248        tx: &ApproveIntegratorTxInfo,
249        signed: &SignedTx,
250        l1_sig: &str,
251    ) -> String {
252        let mut out = String::with_capacity(384);
253        out.push('{');
254        write_ctx_lead(&mut out, tx.context);
255        write_kv_i64(
256            &mut out,
257            "IntegratorAccountIndex",
258            tx.integrator_account_index,
259        );
260        write_kv_u64(
261            &mut out,
262            "MaxPerpsTakerFee",
263            u64::from(tx.max_perps_taker_fee),
264        );
265        write_kv_u64(
266            &mut out,
267            "MaxPerpsMakerFee",
268            u64::from(tx.max_perps_maker_fee),
269        );
270        write_kv_u64(
271            &mut out,
272            "MaxSpotTakerFee",
273            u64::from(tx.max_spot_taker_fee),
274        );
275        write_kv_u64(
276            &mut out,
277            "MaxSpotMakerFee",
278            u64::from(tx.max_spot_maker_fee),
279        );
280        write_kv_i64(&mut out, "ApprovalExpiry", tx.approval_expiry);
281        write_ctx_tail(&mut out, tx.context);
282        write_sig(&mut out, signed);
283        out.push_str("\"L1Sig\":\"");
284        out.push_str(l1_sig);
285        out.push_str("\",");
286        write_attributes_skip_nonce_only(&mut out, &tx.attributes());
287        out.push('}');
288        out
289    }
290}
291
292fn write_ctx_lead(out: &mut String, ctx: TxContext) {
293    write_kv_i64(out, "AccountIndex", ctx.account_index);
294    write_kv_u64(out, "ApiKeyIndex", u64::from(ctx.api_key_index));
295}
296
297fn write_ctx_tail(out: &mut String, ctx: TxContext) {
298    write_kv_i64(out, "ExpiredAt", ctx.expired_at);
299    write_kv_i64(out, "Nonce", ctx.nonce);
300}
301
302fn write_order_info(out: &mut String, order: &OrderInfo) {
303    write_kv_i64(out, "MarketIndex", i64::from(order.market_index));
304    write_kv_i64(out, "ClientOrderIndex", order.client_order_index);
305    write_kv_i64(out, "BaseAmount", order.base_amount);
306    write_kv_u64(out, "Price", u64::from(order.price));
307    write_kv_u64(out, "IsAsk", u64::from(u8::from(order.is_ask)));
308    write_kv_u64(out, "Type", u64::from(order.order_type));
309    write_kv_u64(out, "TimeInForce", u64::from(order.time_in_force));
310    write_kv_u64(out, "ReduceOnly", u64::from(u8::from(order.reduce_only)));
311    write_kv_u64(out, "TriggerPrice", u64::from(order.trigger_price));
312    write_kv_i64(out, "OrderExpiry", order.order_expiry);
313}
314
315fn write_sig(out: &mut String, signed: &SignedTx) {
316    out.push_str("\"Sig\":\"");
317    out.push_str(&B64.encode(signed.sig_bytes));
318    out.push_str("\",");
319}
320
321// Match upstream marshalling: nil-valued attributes are omitted, and a fully
322// empty Create/Modify attribute map is encoded as null.
323fn write_attributes_with_integrator(out: &mut String, attrs: &L2TxAttributes) {
324    if attrs.is_empty() {
325        out.push_str("\"L2TxAttributes\":null");
326        return;
327    }
328
329    out.push_str("\"L2TxAttributes\":{");
330    let mut first = true;
331    if attrs.integrator_account_index != 0 {
332        write_attr_pair(out, &mut first, "1", attrs.integrator_account_index);
333    }
334
335    if attrs.integrator_taker_fee != 0 {
336        write_attr_pair(out, &mut first, "2", u64::from(attrs.integrator_taker_fee));
337    }
338
339    if attrs.integrator_maker_fee != 0 {
340        write_attr_pair(out, &mut first, "3", u64::from(attrs.integrator_maker_fee));
341    }
342
343    if attrs.skip_nonce != 0 {
344        write_attr_pair(out, &mut first, "4", u64::from(attrs.skip_nonce));
345    }
346    out.push('}');
347}
348
349// Cancel/CancelAll/Withdraw/etc.: the FFI wrapper passes only `skip_nonce`,
350// so the marshalled value is `null` when nothing is set, otherwise a single
351// `{"4":1}` entry.
352fn write_attributes_skip_nonce_only(out: &mut String, attrs: &L2TxAttributes) {
353    if attrs.skip_nonce == 0 {
354        out.push_str("\"L2TxAttributes\":null");
355        return;
356    }
357    out.push_str("\"L2TxAttributes\":{\"4\":");
358    write_u64(out, u64::from(attrs.skip_nonce));
359    out.push('}');
360}
361
362fn write_attr_pair(out: &mut String, first: &mut bool, key: &str, value: u64) {
363    if !*first {
364        out.push(',');
365    }
366    *first = false;
367    out.push('"');
368    out.push_str(key);
369    out.push_str("\":");
370    write_u64(out, value);
371}
372
373fn write_kv_i64(out: &mut String, key: &str, value: i64) {
374    out.push('"');
375    out.push_str(key);
376    out.push_str("\":");
377    write_i64(out, value);
378    out.push(',');
379}
380
381fn write_kv_u64(out: &mut String, key: &str, value: u64) {
382    out.push('"');
383    out.push_str(key);
384    out.push_str("\":");
385    write_u64(out, value);
386    out.push(',');
387}
388
389fn write_i64(out: &mut String, value: i64) {
390    write!(out, "{value}").expect("writing into String never fails");
391}
392
393fn write_u64(out: &mut String, value: u64) {
394    write!(out, "{value}").expect("writing into String never fails");
395}
396
397#[cfg(test)]
398mod tests {
399    use proptest::prelude::*;
400    use rstest::rstest;
401    use serde::Deserialize;
402
403    use super::*;
404    use crate::signing::{
405        curve::{SCALAR_BYTES, Scalar},
406        field::Fp,
407        fixtures::{arb_scalar_nonzero, bytes_to_hex, decode_scalar_bytes, hex_to_bytes},
408        tx::types::{NB_ATTRIBUTES_PER_TX, OrderInfo, TxContext},
409    };
410
411    const ORACLE_JSON: &str = include_str!(concat!(
412        env!("CARGO_MANIFEST_DIR"),
413        "/test_data/signing_tx_oracle.json",
414    ));
415
416    #[derive(Debug, Deserialize)]
417    struct OracleFile {
418        vectors: Vec<OracleVector>,
419    }
420
421    #[derive(Debug, Deserialize)]
422    struct OracleVector {
423        kind: String,
424        chain_id: u32,
425        sk: String,
426        account_index: i64,
427        api_key_index: u8,
428        nonce: i64,
429        expired_at: i64,
430        fields: serde_json::Value,
431        tx_type: u8,
432        tx_info: String,
433        tx_hash: String,
434        sig: String,
435    }
436
437    fn ctx_for(v: &OracleVector) -> TxContext {
438        TxContext {
439            account_index: v.account_index,
440            api_key_index: v.api_key_index,
441            nonce: v.nonce,
442            expired_at: v.expired_at,
443        }
444    }
445
446    fn attrs_from(fields: &serde_json::Value) -> L2TxAttributes {
447        L2TxAttributes {
448            integrator_account_index: fields["integrator_account_index"].as_u64().unwrap_or(0),
449            integrator_taker_fee: fields["integrator_taker_fee"].as_u64().unwrap_or(0) as u32,
450            integrator_maker_fee: fields["integrator_maker_fee"].as_u64().unwrap_or(0) as u32,
451            skip_nonce: fields["skip_nonce"].as_u64().unwrap_or(0) as u8,
452        }
453    }
454
455    fn expect_create_order(v: &OracleVector) -> CreateOrderTxInfo {
456        let f = &v.fields;
457        CreateOrderTxInfo {
458            context: ctx_for(v),
459            order: OrderInfo {
460                market_index: f["market_index"].as_i64().unwrap() as i16,
461                client_order_index: f["client_order_index"].as_i64().unwrap(),
462                base_amount: f["base_amount"].as_i64().unwrap(),
463                price: f["price"].as_u64().unwrap() as u32,
464                is_ask: f["is_ask"].as_bool().unwrap(),
465                order_type: f["order_type"].as_u64().unwrap() as u8,
466                time_in_force: f["time_in_force"].as_u64().unwrap() as u8,
467                reduce_only: f["reduce_only"].as_bool().unwrap(),
468                trigger_price: f["trigger_price"].as_u64().unwrap() as u32,
469                order_expiry: f["order_expiry"].as_i64().unwrap(),
470            },
471            attributes: attrs_from(f),
472        }
473    }
474
475    fn expect_cancel_order(v: &OracleVector) -> CancelOrderTxInfo {
476        let f = &v.fields;
477        CancelOrderTxInfo {
478            context: ctx_for(v),
479            market_index: f["market_index"].as_i64().unwrap() as i16,
480            index: f["index"].as_i64().unwrap(),
481            skip_nonce: f["skip_nonce"].as_u64().unwrap_or(0) as u8,
482        }
483    }
484
485    fn expect_modify_order(v: &OracleVector) -> ModifyOrderTxInfo {
486        let f = &v.fields;
487        ModifyOrderTxInfo {
488            context: ctx_for(v),
489            market_index: f["market_index"].as_i64().unwrap() as i16,
490            index: f["index"].as_i64().unwrap(),
491            base_amount: f["base_amount"].as_i64().unwrap(),
492            price: f["price"].as_u64().unwrap() as u32,
493            trigger_price: f["trigger_price"].as_u64().unwrap() as u32,
494            attributes: attrs_from(f),
495        }
496    }
497
498    fn expect_cancel_all_orders(v: &OracleVector) -> CancelAllOrdersTxInfo {
499        let f = &v.fields;
500        CancelAllOrdersTxInfo {
501            context: ctx_for(v),
502            time_in_force: f["time_in_force"].as_u64().unwrap() as u8,
503            scheduled_time_ms: f["scheduled_time_ms"].as_i64().unwrap(),
504            skip_nonce: f["skip_nonce"].as_u64().unwrap_or(0) as u8,
505        }
506    }
507
508    fn expect_update_leverage(v: &OracleVector) -> UpdateLeverageTxInfo {
509        let f = &v.fields;
510        UpdateLeverageTxInfo {
511            context: ctx_for(v),
512            market_index: f["market_index"].as_i64().unwrap() as i16,
513            initial_margin_fraction: f["initial_margin_fraction"].as_u64().unwrap() as u16,
514            margin_mode: f["margin_mode"].as_u64().unwrap() as u8,
515            skip_nonce: f["skip_nonce"].as_u64().unwrap_or(0) as u8,
516        }
517    }
518
519    fn expect_approve_integrator(v: &OracleVector) -> ApproveIntegratorTxInfo {
520        let f = &v.fields;
521        ApproveIntegratorTxInfo {
522            context: ctx_for(v),
523            integrator_account_index: f["integrator_account_index"].as_i64().unwrap(),
524            max_perps_taker_fee: f["max_perps_taker_fee"].as_u64().unwrap() as u32,
525            max_perps_maker_fee: f["max_perps_maker_fee"].as_u64().unwrap() as u32,
526            max_spot_taker_fee: f["max_spot_taker_fee"].as_u64().unwrap() as u32,
527            max_spot_maker_fee: f["max_spot_maker_fee"].as_u64().unwrap() as u32,
528            approval_expiry: f["approval_expiry"].as_i64().unwrap(),
529            skip_nonce: f["skip_nonce"].as_u64().unwrap_or(0) as u8,
530        }
531    }
532
533    fn assert_hash_matches<T: LighterTx>(tx: &T, v: &OracleVector) {
534        let got = compute_tx_hash(tx, v.chain_id);
535        assert_eq!(
536            bytes_to_hex(&got),
537            v.tx_hash,
538            "{}: tx_hash diverged",
539            v.kind,
540        );
541    }
542
543    fn assert_oracle_sig_verifies<T: LighterTx>(tx: &T, v: &OracleVector) {
544        let sig_bytes = hex_to_bytes(&v.sig);
545        assert_eq!(sig_bytes.len(), SIG_BYTES);
546        let mut buf = [0u8; SIG_BYTES];
547        buf.copy_from_slice(&sig_bytes);
548        let sig = Signature::from_le_bytes_reduce(buf);
549
550        let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
551        let pk = sk.public_key();
552        let tx_hash = compute_tx_hash(tx, v.chain_id);
553        let hashed = Fp5::try_from_le_bytes(tx_hash).expect("oracle hash must be canonical");
554
555        assert!(
556            pk.verify(hashed, &sig),
557            "{}: oracle sig must verify against the recomputed hash",
558            v.kind,
559        );
560    }
561
562    fn assert_round_trip_sign<T: LighterTx>(tx: &T, v: &OracleVector) {
563        let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
564        // Pick a nonzero, fixture-derived `k` - any non-zero canonical scalar
565        // is valid. Guarding against `k == 0` and non-canonical limbs makes
566        // the helper fail loudly on the test scaffold rather than producing
567        // an undefined signature if the XOR happens to land on a bad value.
568        let mut k_bytes = decode_scalar_bytes(&v.sk);
569        k_bytes[0] ^= 0x01;
570        let k = Scalar::from_le_bytes_reduce(k_bytes);
571        assert!(!k.is_zero(), "{}: derived k must be non-zero", v.kind);
572        assert!(k.is_canonical(), "{}: derived k must be canonical", v.kind,);
573
574        let signed = sign_tx(tx, v.chain_id, &sk, k);
575        assert_eq!(
576            bytes_to_hex(&signed.tx_hash),
577            v.tx_hash,
578            "{}: sign_tx tx_hash diverged",
579            v.kind,
580        );
581        assert_eq!(
582            signed.tx_hash_hex(),
583            v.tx_hash,
584            "{}: tx_hash_hex must render the venue's lowercase hex form",
585            v.kind,
586        );
587        let pk = sk.public_key();
588        let hashed = Fp5::try_from_le_bytes(signed.tx_hash).unwrap();
589        assert!(
590            pk.verify(hashed, &signed.sig),
591            "{}: round-trip sig must verify",
592            v.kind,
593        );
594    }
595
596    #[rstest]
597    fn oracle_tx_hash_matches_create_order() {
598        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
599        for v in suite.vectors.iter().filter(|v| v.kind == "create_order") {
600            assert_eq!(v.tx_type, 14);
601            let tx = expect_create_order(v);
602            assert_hash_matches(&tx, v);
603            assert_oracle_sig_verifies(&tx, v);
604            assert_round_trip_sign(&tx, v);
605        }
606    }
607
608    #[rstest]
609    fn oracle_tx_hash_matches_cancel_order() {
610        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
611        for v in suite.vectors.iter().filter(|v| v.kind == "cancel_order") {
612            assert_eq!(v.tx_type, 15);
613            let tx = expect_cancel_order(v);
614            assert_hash_matches(&tx, v);
615            assert_oracle_sig_verifies(&tx, v);
616            assert_round_trip_sign(&tx, v);
617        }
618    }
619
620    #[rstest]
621    fn oracle_tx_hash_matches_modify_order() {
622        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
623        for v in suite.vectors.iter().filter(|v| v.kind == "modify_order") {
624            assert_eq!(v.tx_type, 17);
625            let tx = expect_modify_order(v);
626            assert_hash_matches(&tx, v);
627            assert_oracle_sig_verifies(&tx, v);
628            assert_round_trip_sign(&tx, v);
629        }
630    }
631
632    #[rstest]
633    fn oracle_tx_hash_matches_cancel_all_orders() {
634        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
635        for v in suite
636            .vectors
637            .iter()
638            .filter(|v| v.kind == "cancel_all_orders")
639        {
640            assert_eq!(v.tx_type, 16);
641            let tx = expect_cancel_all_orders(v);
642            assert_hash_matches(&tx, v);
643            assert_oracle_sig_verifies(&tx, v);
644            assert_round_trip_sign(&tx, v);
645        }
646    }
647
648    #[rstest]
649    fn oracle_tx_hash_matches_update_leverage() {
650        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
651        for v in suite.vectors.iter().filter(|v| v.kind == "update_leverage") {
652            assert_eq!(v.tx_type, 20);
653            let tx = expect_update_leverage(v);
654            assert_hash_matches(&tx, v);
655            assert_oracle_sig_verifies(&tx, v);
656            assert_round_trip_sign(&tx, v);
657        }
658    }
659
660    #[rstest]
661    fn oracle_tx_hash_matches_approve_integrator() {
662        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
663        for v in suite
664            .vectors
665            .iter()
666            .filter(|v| v.kind == "approve_integrator")
667        {
668            assert_eq!(v.tx_type, 45);
669            let tx = expect_approve_integrator(v);
670            assert_hash_matches(&tx, v);
671            assert_oracle_sig_verifies(&tx, v);
672            assert_round_trip_sign(&tx, v);
673        }
674    }
675
676    /// Replace the random-`k`-driven `Sig` block with a stable placeholder
677    /// so two JSON renderings of the same body can be compared byte-for-byte
678    /// regardless of which `k` produced them.
679    fn redact_sig(json: &str) -> String {
680        let start = json.find("\"Sig\":\"").expect("Sig key missing");
681        let after_open = start + "\"Sig\":\"".len();
682        let close = json[after_open..]
683            .find('"')
684            .map(|i| after_open + i)
685            .expect("Sig value not closed");
686        let mut out = String::with_capacity(json.len());
687        out.push_str(&json[..after_open]);
688        out.push_str("REDACTED");
689        out.push_str(&json[close..]);
690        out
691    }
692
693    fn signed_with_fixture_k(
694        v: &OracleVector,
695        sk: &PrivateKey,
696        signed_tx: impl Fn(Scalar) -> SignedTx,
697    ) -> SignedTx {
698        let _ = sk;
699        let mut k_bytes = decode_scalar_bytes(&v.sk);
700        k_bytes[0] ^= 0x01;
701        signed_tx(Scalar::from_le_bytes_reduce(k_bytes))
702    }
703
704    #[rstest]
705    fn create_order_json_byte_equals_oracle_modulo_sig() {
706        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
707        for v in suite.vectors.iter().filter(|v| v.kind == "create_order") {
708            let tx = expect_create_order(v);
709            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
710            let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
711            let json = TxInfoJson::create_order(&tx, &signed);
712            assert_eq!(
713                redact_sig(&json),
714                redact_sig(&v.tx_info),
715                "create_order tx_info diverged",
716            );
717        }
718    }
719
720    #[rstest]
721    #[case(2)]
722    #[case(3)]
723    #[case(4)]
724    #[case(5)]
725    fn oracle_covers_conditional_create_order_type(#[case] order_type: u64) {
726        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
727        assert!(suite.vectors.iter().any(|v| {
728            v.kind == "create_order" && v.fields["order_type"].as_u64() == Some(order_type)
729        }));
730    }
731
732    #[rstest]
733    #[case("create_order")]
734    #[case("modify_order")]
735    fn oracle_covers_production_integrator_attributes(#[case] kind: &str) {
736        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
737        assert!(suite.vectors.iter().any(|v| {
738            v.kind == kind
739                && v.fields["integrator_account_index"].as_u64() == Some(723_813)
740                && v.fields["integrator_taker_fee"].as_u64() == Some(0)
741                && v.fields["integrator_maker_fee"].as_u64() == Some(0)
742        }));
743    }
744
745    #[rstest]
746    fn cancel_order_json_emits_null_attributes_when_empty() {
747        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
748        for v in suite.vectors.iter().filter(|v| v.kind == "cancel_order") {
749            let tx = expect_cancel_order(v);
750            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
751            let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
752            let json = TxInfoJson::cancel_order(&tx, &signed);
753            assert_eq!(
754                redact_sig(&json),
755                redact_sig(&v.tx_info),
756                "cancel_order tx_info diverged",
757            );
758        }
759    }
760
761    #[rstest]
762    fn modify_order_json_byte_equals_oracle_modulo_sig() {
763        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
764        for v in suite.vectors.iter().filter(|v| v.kind == "modify_order") {
765            let tx = expect_modify_order(v);
766            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
767            let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
768            let json = TxInfoJson::modify_order(&tx, &signed);
769            assert_eq!(
770                redact_sig(&json),
771                redact_sig(&v.tx_info),
772                "modify_order tx_info diverged",
773            );
774        }
775    }
776
777    fn stub_signed() -> SignedTx {
778        SignedTx {
779            tx_hash: [0u8; TX_HASH_BYTES],
780            sig: Signature {
781                s: Scalar::from_le_bytes_reduce([0u8; SCALAR_BYTES]),
782                e: Scalar::from_le_bytes_reduce([0u8; SCALAR_BYTES]),
783            },
784            sig_bytes: [0u8; SIG_BYTES],
785        }
786    }
787
788    fn stub_context() -> TxContext {
789        TxContext {
790            account_index: 12_345,
791            api_key_index: 5,
792            nonce: 7,
793            expired_at: 1_777_804_395_089,
794        }
795    }
796
797    #[rstest]
798    fn cancel_all_orders_json_byte_equals_oracle_modulo_sig() {
799        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
800        for v in suite
801            .vectors
802            .iter()
803            .filter(|v| v.kind == "cancel_all_orders")
804        {
805            let tx = expect_cancel_all_orders(v);
806            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
807            let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
808            let json = TxInfoJson::cancel_all_orders(&tx, &signed);
809            assert_eq!(
810                redact_sig(&json),
811                redact_sig(&v.tx_info),
812                "cancel_all_orders tx_info diverged",
813            );
814        }
815    }
816
817    #[rstest]
818    fn cancel_all_orders_json_emits_skip_nonce_attr_when_set() {
819        let tx = CancelAllOrdersTxInfo {
820            context: stub_context(),
821            time_in_force: 1, // Scheduled
822            scheduled_time_ms: 1_800_000_000_000,
823            skip_nonce: 1,
824        };
825        let json = TxInfoJson::cancel_all_orders(&tx, &stub_signed());
826        let expected = concat!(
827            r#"{"AccountIndex":12345,"ApiKeyIndex":5,"#,
828            r#""TimeInForce":1,"Time":1800000000000,"#,
829            r#""ExpiredAt":1777804395089,"Nonce":7,"#,
830            r#""Sig":"REDACTED","L2TxAttributes":{"4":1}}"#,
831        );
832        assert_eq!(redact_sig(&json), expected);
833    }
834
835    #[rstest]
836    fn update_leverage_json_byte_equals_oracle_modulo_sig() {
837        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
838        for v in suite.vectors.iter().filter(|v| v.kind == "update_leverage") {
839            let tx = expect_update_leverage(v);
840            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
841            let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
842            let json = TxInfoJson::update_leverage(&tx, &signed);
843            assert_eq!(
844                redact_sig(&json),
845                redact_sig(&v.tx_info),
846                "update_leverage tx_info diverged",
847            );
848        }
849    }
850
851    #[rstest]
852    fn approve_integrator_json_byte_equals_oracle_modulo_sig() {
853        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
854        for v in suite
855            .vectors
856            .iter()
857            .filter(|v| v.kind == "approve_integrator")
858        {
859            let tx = expect_approve_integrator(v);
860            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
861            let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
862            let json = TxInfoJson::approve_integrator(&tx, &signed, "");
863            assert_eq!(
864                redact_sig(&json),
865                redact_sig(&v.tx_info),
866                "approve_integrator tx_info diverged",
867            );
868        }
869    }
870
871    fn arb_tx_context() -> impl Strategy<Value = TxContext> {
872        (any::<i64>(), any::<u8>(), any::<i64>(), any::<i64>()).prop_map(
873            |(account_index, api_key_index, nonce, expired_at)| TxContext {
874                account_index,
875                api_key_index,
876                nonce,
877                expired_at,
878            },
879        )
880    }
881
882    fn arb_order_info() -> impl Strategy<Value = OrderInfo> {
883        (
884            any::<i16>(),
885            any::<i64>(),
886            any::<i64>(),
887            any::<u32>(),
888            any::<bool>(),
889            any::<u8>(),
890            any::<u8>(),
891            any::<bool>(),
892            any::<u32>(),
893            any::<i64>(),
894        )
895            .prop_map(
896                |(
897                    market_index,
898                    client_order_index,
899                    base_amount,
900                    price,
901                    is_ask,
902                    order_type,
903                    time_in_force,
904                    reduce_only,
905                    trigger_price,
906                    order_expiry,
907                )| OrderInfo {
908                    market_index,
909                    client_order_index,
910                    base_amount,
911                    price,
912                    is_ask,
913                    order_type,
914                    time_in_force,
915                    reduce_only,
916                    trigger_price,
917                    order_expiry,
918                },
919            )
920    }
921
922    fn arb_l2_attributes() -> impl Strategy<Value = L2TxAttributes> {
923        (any::<u64>(), any::<u32>(), any::<u32>(), any::<u8>()).prop_map(
924            |(integrator_account_index, integrator_taker_fee, integrator_maker_fee, skip_nonce)| {
925                L2TxAttributes {
926                    integrator_account_index,
927                    integrator_taker_fee,
928                    integrator_maker_fee,
929                    skip_nonce,
930                }
931            },
932        )
933    }
934
935    fn arb_create_order() -> impl Strategy<Value = CreateOrderTxInfo> {
936        (arb_tx_context(), arb_order_info(), arb_l2_attributes()).prop_map(
937            |(context, order, attributes)| CreateOrderTxInfo {
938                context,
939                order,
940                attributes,
941            },
942        )
943    }
944
945    proptest! {
946        /// `compute_tx_hash` is deterministic over identical input.
947        #[rstest]
948        fn prop_compute_tx_hash_deterministic(tx in arb_create_order(), chain_id in any::<u32>()) {
949            prop_assert_eq!(compute_tx_hash(&tx, chain_id), compute_tx_hash(&tx, chain_id));
950        }
951
952        /// Empty attributes short-circuit through the body-only branch:
953        /// `compute_tx_hash` matches `hash_to_quintic_extension(body_elems)`
954        /// directly when `attributes.is_empty()`.
955        #[rstest]
956        fn prop_empty_attrs_branch_uses_body_hash_only(
957            mut tx in arb_create_order(),
958            chain_id in any::<u32>(),
959        ) {
960            tx.attributes = L2TxAttributes::default();
961            let from_pipeline = compute_tx_hash(&tx, chain_id);
962            let from_body =
963                hash_to_quintic_extension(&tx.hash_elements(chain_id)).to_le_bytes();
964            prop_assert_eq!(from_pipeline, from_body);
965        }
966
967        /// Non-empty attributes engage the aggregation branch: the pipeline
968        /// hash matches `hash_two_to_quintic(body_digest, attr_digest)`
969        /// computed explicitly from the preimage. Pins the branch and the
970        /// aggregation formula deterministically (no collision-resistance
971        /// assumption).
972        #[rstest]
973        fn prop_non_empty_attrs_matches_aggregation_formula(
974            mut tx in arb_create_order(),
975            chain_id in any::<u32>(),
976        ) {
977            // Force at least one attribute slot populated so the aggregation
978            // branch fires.
979            tx.attributes = L2TxAttributes {
980                integrator_account_index: 0,
981                integrator_taker_fee: 0,
982                integrator_maker_fee: 0,
983                skip_nonce: 1,
984            };
985            let from_pipeline = compute_tx_hash(&tx, chain_id);
986            let expected = explicit_tx_hash(&tx, chain_id);
987            prop_assert_eq!(from_pipeline, expected);
988        }
989    }
990
991    /// Recompute the tx hash explicitly from `hash_elements` and the
992    /// normalized attribute pairs, mirroring the two branches inside
993    /// `compute_tx_hash_fp5`. Used by the proptests below to assert the
994    /// pipeline hash equals the explicit branch formula without making a
995    /// collision-resistance assumption.
996    fn explicit_tx_hash<T: LighterTx>(tx: &T, chain_id: u32) -> [u8; TX_HASH_BYTES] {
997        let body_elems = tx.hash_elements(chain_id);
998        let body_digest = hash_to_quintic_extension(&body_elems);
999        let attrs = tx.attributes();
1000        let result = if attrs.is_empty() {
1001            body_digest
1002        } else {
1003            let pairs = attrs.normalized_pairs();
1004            let mut elems = [Fp::from_u64_reduce(0); NB_ATTRIBUTES_PER_TX * 2];
1005            for (i, (ty, val)) in pairs.iter().enumerate() {
1006                elems[i * 2] = Fp::from_u64_reduce(u64::from(*ty));
1007                elems[i * 2 + 1] = Fp::from_u64_reduce(*val);
1008            }
1009            let attr_digest = hash_to_quintic_extension(&elems);
1010            hash_two_to_quintic(body_digest, attr_digest)
1011        };
1012        result.to_le_bytes()
1013    }
1014
1015    /// Mutator selector for `prop_field_change_changes_hash`. Each variant
1016    /// names a body, attribute, or context field that participates in the
1017    /// signed hash; mutating it MUST change the hash.
1018    #[derive(Debug, Clone, Copy)]
1019    enum CreateOrderField {
1020        ChainId,
1021        AccountIndex,
1022        ApiKeyIndex,
1023        Nonce,
1024        ExpiredAt,
1025        MarketIndex,
1026        ClientOrderIndex,
1027        BaseAmount,
1028        Price,
1029        IsAsk,
1030        OrderType,
1031        TimeInForce,
1032        ReduceOnly,
1033        TriggerPrice,
1034        OrderExpiry,
1035        IntegratorAccountIndex,
1036        IntegratorTakerFee,
1037        IntegratorMakerFee,
1038        SkipNonce,
1039    }
1040
1041    fn arb_create_order_field() -> impl Strategy<Value = CreateOrderField> {
1042        prop_oneof![
1043            Just(CreateOrderField::ChainId),
1044            Just(CreateOrderField::AccountIndex),
1045            Just(CreateOrderField::ApiKeyIndex),
1046            Just(CreateOrderField::Nonce),
1047            Just(CreateOrderField::ExpiredAt),
1048            Just(CreateOrderField::MarketIndex),
1049            Just(CreateOrderField::ClientOrderIndex),
1050            Just(CreateOrderField::BaseAmount),
1051            Just(CreateOrderField::Price),
1052            Just(CreateOrderField::IsAsk),
1053            Just(CreateOrderField::OrderType),
1054            Just(CreateOrderField::TimeInForce),
1055            Just(CreateOrderField::ReduceOnly),
1056            Just(CreateOrderField::TriggerPrice),
1057            Just(CreateOrderField::OrderExpiry),
1058            Just(CreateOrderField::IntegratorAccountIndex),
1059            Just(CreateOrderField::IntegratorTakerFee),
1060            Just(CreateOrderField::IntegratorMakerFee),
1061            Just(CreateOrderField::SkipNonce),
1062        ]
1063    }
1064
1065    /// Apply a `+ 1` (or `!` for booleans) mutation to the named field.
1066    /// `chain_id` is mutated in-place via the second tuple element; all
1067    /// other fields are mutated on the returned `CreateOrderTxInfo`.
1068    fn mutate_create_order_field(
1069        base: CreateOrderTxInfo,
1070        chain_id: u32,
1071        field: CreateOrderField,
1072    ) -> (CreateOrderTxInfo, u32) {
1073        let mut alt = base;
1074        let mut chain = chain_id;
1075        match field {
1076            CreateOrderField::ChainId => chain = chain.wrapping_add(1),
1077            CreateOrderField::AccountIndex => {
1078                alt.context.account_index = alt.context.account_index.wrapping_add(1);
1079            }
1080            CreateOrderField::ApiKeyIndex => {
1081                alt.context.api_key_index = alt.context.api_key_index.wrapping_add(1);
1082            }
1083            CreateOrderField::Nonce => alt.context.nonce = alt.context.nonce.wrapping_add(1),
1084            CreateOrderField::ExpiredAt => {
1085                alt.context.expired_at = alt.context.expired_at.wrapping_add(1);
1086            }
1087            CreateOrderField::MarketIndex => {
1088                alt.order.market_index = alt.order.market_index.wrapping_add(1);
1089            }
1090            CreateOrderField::ClientOrderIndex => {
1091                alt.order.client_order_index = alt.order.client_order_index.wrapping_add(1);
1092            }
1093            CreateOrderField::BaseAmount => {
1094                alt.order.base_amount = alt.order.base_amount.wrapping_add(1);
1095            }
1096            CreateOrderField::Price => alt.order.price = alt.order.price.wrapping_add(1),
1097            CreateOrderField::IsAsk => alt.order.is_ask = !alt.order.is_ask,
1098            CreateOrderField::OrderType => {
1099                alt.order.order_type = alt.order.order_type.wrapping_add(1);
1100            }
1101            CreateOrderField::TimeInForce => {
1102                alt.order.time_in_force = alt.order.time_in_force.wrapping_add(1);
1103            }
1104            CreateOrderField::ReduceOnly => alt.order.reduce_only = !alt.order.reduce_only,
1105            CreateOrderField::TriggerPrice => {
1106                alt.order.trigger_price = alt.order.trigger_price.wrapping_add(1);
1107            }
1108            CreateOrderField::OrderExpiry => {
1109                alt.order.order_expiry = alt.order.order_expiry.wrapping_add(1);
1110            }
1111            CreateOrderField::IntegratorAccountIndex => {
1112                alt.attributes.integrator_account_index =
1113                    alt.attributes.integrator_account_index.wrapping_add(1);
1114            }
1115            CreateOrderField::IntegratorTakerFee => {
1116                alt.attributes.integrator_taker_fee =
1117                    alt.attributes.integrator_taker_fee.wrapping_add(1);
1118            }
1119            CreateOrderField::IntegratorMakerFee => {
1120                alt.attributes.integrator_maker_fee =
1121                    alt.attributes.integrator_maker_fee.wrapping_add(1);
1122            }
1123            CreateOrderField::SkipNonce => {
1124                alt.attributes.skip_nonce = alt.attributes.skip_nonce.wrapping_add(1);
1125            }
1126        }
1127        (alt, chain)
1128    }
1129
1130    proptest! {
1131        /// Mutating any single body, attribute, or context field changes the
1132        /// signed *preimage* (`hash_elements` plus `attributes()` for
1133        /// attribute fields). Pins body-element ordering and attribute-slot
1134        /// participation deterministically - a regression that drops or
1135        /// swaps a field makes at least one mutation a no-op on the
1136        /// preimage. Asserting on the preimage rather than the digest
1137        /// avoids the hash-collision overreach (Poseidon compresses to
1138        /// ~320 bits, so universal-distinctness on digests is not a
1139        /// primitive contract).
1140        #[rstest]
1141        fn prop_field_change_changes_preimage(
1142            base in arb_create_order(),
1143            chain_id in any::<u32>(),
1144            field in arb_create_order_field(),
1145        ) {
1146            let (alt, chain) = mutate_create_order_field(base, chain_id, field);
1147            let base_preimage = (base.hash_elements(chain_id), base.attributes());
1148            let alt_preimage = (alt.hash_elements(chain), alt.attributes());
1149            prop_assert_ne!(
1150                base_preimage,
1151                alt_preimage,
1152                "mutation {:?} did not change preimage",
1153                field,
1154            );
1155        }
1156
1157        /// `compute_tx_hash` matches the explicit branch formula derived
1158        /// from `hash_elements` and `normalized_pairs`. Pins both branches
1159        /// (empty / non-empty attributes) and the branch selector
1160        /// (`L2TxAttributes::is_empty`) deterministically.
1161        #[rstest]
1162        fn prop_compute_tx_hash_matches_explicit_formula(
1163            tx in arb_create_order(),
1164            chain_id in any::<u32>(),
1165        ) {
1166            prop_assert_eq!(compute_tx_hash(&tx, chain_id), explicit_tx_hash(&tx, chain_id));
1167        }
1168
1169        /// `sign_tx` followed by `verify` succeeds for any well-formed
1170        /// CreateOrder body and any non-zero canonical nonce / secret key.
1171        #[rstest]
1172        fn prop_sign_then_verify_for_arbitrary_tx(
1173            tx in arb_create_order(),
1174            chain_id in any::<u32>(),
1175            sk in arb_scalar_nonzero(),
1176            k in arb_scalar_nonzero(),
1177        ) {
1178            let private_key = PrivateKey::from_scalar(sk);
1179            let pk = private_key.public_key();
1180            let signed = sign_tx(&tx, chain_id, &private_key, k);
1181
1182            // Recomputing the hash through `compute_tx_hash` must match
1183            // the value returned by `sign_tx`.
1184            prop_assert_eq!(signed.tx_hash, compute_tx_hash(&tx, chain_id));
1185
1186            let hashed = Fp5::try_from_le_bytes(signed.tx_hash)
1187                .expect("tx hash must encode a canonical Fp5");
1188            prop_assert!(pk.verify(hashed, &signed.sig));
1189        }
1190    }
1191
1192    #[rstest]
1193    fn cancel_order_json_emits_skip_nonce_only_attribute() {
1194        // Synthesised case: skip_nonce=1, no integrator slots
1195        let tx = CancelOrderTxInfo {
1196            context: TxContext {
1197                account_index: 1,
1198                api_key_index: 0,
1199                nonce: 0,
1200                expired_at: 0,
1201            },
1202            market_index: 0,
1203            index: 1,
1204            skip_nonce: 1,
1205        };
1206        let sk = PrivateKey::from_le_bytes_reduce([0x42; SCALAR_BYTES]);
1207        let signed = sign_tx(&tx, 300, &sk, Scalar::ONE);
1208        let json = TxInfoJson::cancel_order(&tx, &signed);
1209        assert!(
1210            json.ends_with(",\"L2TxAttributes\":{\"4\":1}}"),
1211            "was {json}"
1212        );
1213    }
1214}