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 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 `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", 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", 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", 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 marshaling: 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 marshaled 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(),
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(),
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(),
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(),
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 assertion 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 tx_info_json_renders_widened_market_index_as_number() {
819        let order = OrderInfo {
820            market_index: 40_000,
821            client_order_index: 123,
822            base_amount: 1_000,
823            price: 405_000,
824            is_ask: true,
825            order_type: 0,
826            time_in_force: 1,
827            reduce_only: false,
828            trigger_price: 0,
829            order_expiry: 1_735_689_600_000,
830        };
831
832        let create = CreateOrderTxInfo {
833            context: stub_context(),
834            order,
835            attributes: L2TxAttributes::default(),
836        };
837
838        let cancel = CancelOrderTxInfo {
839            context: stub_context(),
840            market_index: 40_000,
841            index: 123,
842            skip_nonce: 0,
843        };
844
845        let modify = ModifyOrderTxInfo {
846            context: stub_context(),
847            market_index: 40_000,
848            index: 123,
849            base_amount: 1_100,
850            price: 410_000,
851            trigger_price: 0,
852            attributes: L2TxAttributes::default(),
853        };
854
855        for json in [
856            TxInfoJson::create_order(&create, &stub_signed()),
857            TxInfoJson::cancel_order(&cancel, &stub_signed()),
858            TxInfoJson::modify_order(&modify, &stub_signed()),
859        ] {
860            assert!(
861                json.contains(r#""MarketIndex":40000"#),
862                "widened MarketIndex must render unquoted, was {json}",
863            );
864        }
865    }
866
867    #[rstest]
868    fn cancel_all_orders_json_emits_skip_nonce_attr_when_set() {
869        let tx = CancelAllOrdersTxInfo {
870            context: stub_context(),
871            time_in_force: 1, // Scheduled
872            scheduled_time_ms: 1_800_000_000_000,
873            skip_nonce: 1,
874        };
875        let json = TxInfoJson::cancel_all_orders(&tx, &stub_signed());
876        let expected = concat!(
877            r#"{"AccountIndex":12345,"ApiKeyIndex":5,"#,
878            r#""TimeInForce":1,"Time":1800000000000,"#,
879            r#""ExpiredAt":1777804395089,"Nonce":7,"#,
880            r#""Sig":"REDACTED","L2TxAttributes":{"4":1}}"#,
881        );
882        assert_eq!(redact_sig(&json), expected);
883    }
884
885    #[rstest]
886    fn update_leverage_json_byte_equals_oracle_modulo_sig() {
887        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
888        for v in suite.vectors.iter().filter(|v| v.kind == "update_leverage") {
889            let tx = expect_update_leverage(v);
890            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
891            let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
892            let json = TxInfoJson::update_leverage(&tx, &signed);
893            assert_eq!(
894                redact_sig(&json),
895                redact_sig(&v.tx_info),
896                "update_leverage tx_info diverged",
897            );
898        }
899    }
900
901    #[rstest]
902    fn approve_integrator_json_byte_equals_oracle_modulo_sig() {
903        let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
904        for v in suite
905            .vectors
906            .iter()
907            .filter(|v| v.kind == "approve_integrator")
908        {
909            let tx = expect_approve_integrator(v);
910            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
911            let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
912            let json = TxInfoJson::approve_integrator(&tx, &signed, "");
913            assert_eq!(
914                redact_sig(&json),
915                redact_sig(&v.tx_info),
916                "approve_integrator tx_info diverged",
917            );
918        }
919    }
920
921    fn arb_tx_context() -> impl Strategy<Value = TxContext> {
922        (any::<i64>(), any::<u8>(), any::<i64>(), any::<i64>()).prop_map(
923            |(account_index, api_key_index, nonce, expired_at)| TxContext {
924                account_index,
925                api_key_index,
926                nonce,
927                expired_at,
928            },
929        )
930    }
931
932    fn arb_order_info() -> impl Strategy<Value = OrderInfo> {
933        (
934            any::<i64>(),
935            any::<i64>(),
936            any::<i64>(),
937            any::<u32>(),
938            any::<bool>(),
939            any::<u8>(),
940            any::<u8>(),
941            any::<bool>(),
942            any::<u32>(),
943            any::<i64>(),
944        )
945            .prop_map(
946                |(
947                    market_index,
948                    client_order_index,
949                    base_amount,
950                    price,
951                    is_ask,
952                    order_type,
953                    time_in_force,
954                    reduce_only,
955                    trigger_price,
956                    order_expiry,
957                )| OrderInfo {
958                    market_index,
959                    client_order_index,
960                    base_amount,
961                    price,
962                    is_ask,
963                    order_type,
964                    time_in_force,
965                    reduce_only,
966                    trigger_price,
967                    order_expiry,
968                },
969            )
970    }
971
972    fn arb_l2_attributes() -> impl Strategy<Value = L2TxAttributes> {
973        (any::<u64>(), any::<u32>(), any::<u32>(), any::<u8>()).prop_map(
974            |(integrator_account_index, integrator_taker_fee, integrator_maker_fee, skip_nonce)| {
975                L2TxAttributes {
976                    integrator_account_index,
977                    integrator_taker_fee,
978                    integrator_maker_fee,
979                    skip_nonce,
980                }
981            },
982        )
983    }
984
985    fn arb_create_order() -> impl Strategy<Value = CreateOrderTxInfo> {
986        (arb_tx_context(), arb_order_info(), arb_l2_attributes()).prop_map(
987            |(context, order, attributes)| CreateOrderTxInfo {
988                context,
989                order,
990                attributes,
991            },
992        )
993    }
994
995    proptest! {
996        /// `compute_tx_hash` is deterministic over identical input.
997        #[rstest]
998        fn prop_compute_tx_hash_deterministic(tx in arb_create_order(), chain_id in any::<u32>()) {
999            prop_assert_eq!(compute_tx_hash(&tx, chain_id), compute_tx_hash(&tx, chain_id));
1000        }
1001
1002        /// Empty attributes short-circuit through the body-only branch:
1003        /// `compute_tx_hash` matches `hash_to_quintic_extension(body_elems)`
1004        /// directly when `attributes.is_empty()`.
1005        #[rstest]
1006        fn prop_empty_attrs_branch_uses_body_hash_only(
1007            mut tx in arb_create_order(),
1008            chain_id in any::<u32>(),
1009        ) {
1010            tx.attributes = L2TxAttributes::default();
1011            let from_pipeline = compute_tx_hash(&tx, chain_id);
1012            let from_body =
1013                hash_to_quintic_extension(&tx.hash_elements(chain_id)).to_le_bytes();
1014            prop_assert_eq!(from_pipeline, from_body);
1015        }
1016
1017        /// Non-empty attributes engage the aggregation branch: the pipeline
1018        /// hash matches `hash_two_to_quintic(body_digest, attr_digest)`
1019        /// computed explicitly from the preimage. Pins the branch and the
1020        /// aggregation formula deterministically (no collision-resistance
1021        /// assumption).
1022        #[rstest]
1023        fn prop_non_empty_attrs_matches_aggregation_formula(
1024            mut tx in arb_create_order(),
1025            chain_id in any::<u32>(),
1026        ) {
1027            // Force at least one attribute slot populated so the aggregation
1028            // branch fires.
1029            tx.attributes = L2TxAttributes {
1030                integrator_account_index: 0,
1031                integrator_taker_fee: 0,
1032                integrator_maker_fee: 0,
1033                skip_nonce: 1,
1034            };
1035            let from_pipeline = compute_tx_hash(&tx, chain_id);
1036            let expected = explicit_tx_hash(&tx, chain_id);
1037            prop_assert_eq!(from_pipeline, expected);
1038        }
1039    }
1040
1041    /// Recompute the tx hash explicitly from `hash_elements` and the
1042    /// normalized attribute pairs, mirroring the two branches inside
1043    /// `compute_tx_hash_fp5`. Used by the proptests below to assert the
1044    /// pipeline hash equals the explicit branch formula without making a
1045    /// collision-resistance assumption.
1046    fn explicit_tx_hash<T: LighterTx>(tx: &T, chain_id: u32) -> [u8; TX_HASH_BYTES] {
1047        let body_elems = tx.hash_elements(chain_id);
1048        let body_digest = hash_to_quintic_extension(&body_elems);
1049        let attrs = tx.attributes();
1050        let result = if attrs.is_empty() {
1051            body_digest
1052        } else {
1053            let pairs = attrs.normalized_pairs();
1054            let mut elems = [Fp::from_u64_reduce(0); NB_ATTRIBUTES_PER_TX * 2];
1055            for (i, (ty, val)) in pairs.iter().enumerate() {
1056                elems[i * 2] = Fp::from_u64_reduce(u64::from(*ty));
1057                elems[i * 2 + 1] = Fp::from_u64_reduce(*val);
1058            }
1059            let attr_digest = hash_to_quintic_extension(&elems);
1060            hash_two_to_quintic(body_digest, attr_digest)
1061        };
1062        result.to_le_bytes()
1063    }
1064
1065    /// Mutator selector for `prop_field_change_changes_hash`. Each variant
1066    /// names a body, attribute, or context field that participates in the
1067    /// signed hash; mutating it MUST change the hash.
1068    #[derive(Debug, Clone, Copy)]
1069    enum CreateOrderField {
1070        ChainId,
1071        AccountIndex,
1072        ApiKeyIndex,
1073        Nonce,
1074        ExpiredAt,
1075        MarketIndex,
1076        ClientOrderIndex,
1077        BaseAmount,
1078        Price,
1079        IsAsk,
1080        OrderType,
1081        TimeInForce,
1082        ReduceOnly,
1083        TriggerPrice,
1084        OrderExpiry,
1085        IntegratorAccountIndex,
1086        IntegratorTakerFee,
1087        IntegratorMakerFee,
1088        SkipNonce,
1089    }
1090
1091    fn arb_create_order_field() -> impl Strategy<Value = CreateOrderField> {
1092        prop_oneof![
1093            Just(CreateOrderField::ChainId),
1094            Just(CreateOrderField::AccountIndex),
1095            Just(CreateOrderField::ApiKeyIndex),
1096            Just(CreateOrderField::Nonce),
1097            Just(CreateOrderField::ExpiredAt),
1098            Just(CreateOrderField::MarketIndex),
1099            Just(CreateOrderField::ClientOrderIndex),
1100            Just(CreateOrderField::BaseAmount),
1101            Just(CreateOrderField::Price),
1102            Just(CreateOrderField::IsAsk),
1103            Just(CreateOrderField::OrderType),
1104            Just(CreateOrderField::TimeInForce),
1105            Just(CreateOrderField::ReduceOnly),
1106            Just(CreateOrderField::TriggerPrice),
1107            Just(CreateOrderField::OrderExpiry),
1108            Just(CreateOrderField::IntegratorAccountIndex),
1109            Just(CreateOrderField::IntegratorTakerFee),
1110            Just(CreateOrderField::IntegratorMakerFee),
1111            Just(CreateOrderField::SkipNonce),
1112        ]
1113    }
1114
1115    /// Apply a `+ 1` (or `!` for booleans) mutation to the named field.
1116    /// `chain_id` is mutated in-place via the second tuple element; all
1117    /// other fields are mutated on the returned `CreateOrderTxInfo`.
1118    fn mutate_create_order_field(
1119        base: CreateOrderTxInfo,
1120        chain_id: u32,
1121        field: CreateOrderField,
1122    ) -> (CreateOrderTxInfo, u32) {
1123        let mut alt = base;
1124        let mut chain = chain_id;
1125        match field {
1126            CreateOrderField::ChainId => chain = chain.wrapping_add(1),
1127            CreateOrderField::AccountIndex => {
1128                alt.context.account_index = alt.context.account_index.wrapping_add(1);
1129            }
1130            CreateOrderField::ApiKeyIndex => {
1131                alt.context.api_key_index = alt.context.api_key_index.wrapping_add(1);
1132            }
1133            CreateOrderField::Nonce => alt.context.nonce = alt.context.nonce.wrapping_add(1),
1134            CreateOrderField::ExpiredAt => {
1135                alt.context.expired_at = alt.context.expired_at.wrapping_add(1);
1136            }
1137            CreateOrderField::MarketIndex => {
1138                alt.order.market_index = alt.order.market_index.wrapping_add(1);
1139            }
1140            CreateOrderField::ClientOrderIndex => {
1141                alt.order.client_order_index = alt.order.client_order_index.wrapping_add(1);
1142            }
1143            CreateOrderField::BaseAmount => {
1144                alt.order.base_amount = alt.order.base_amount.wrapping_add(1);
1145            }
1146            CreateOrderField::Price => alt.order.price = alt.order.price.wrapping_add(1),
1147            CreateOrderField::IsAsk => alt.order.is_ask = !alt.order.is_ask,
1148            CreateOrderField::OrderType => {
1149                alt.order.order_type = alt.order.order_type.wrapping_add(1);
1150            }
1151            CreateOrderField::TimeInForce => {
1152                alt.order.time_in_force = alt.order.time_in_force.wrapping_add(1);
1153            }
1154            CreateOrderField::ReduceOnly => alt.order.reduce_only = !alt.order.reduce_only,
1155            CreateOrderField::TriggerPrice => {
1156                alt.order.trigger_price = alt.order.trigger_price.wrapping_add(1);
1157            }
1158            CreateOrderField::OrderExpiry => {
1159                alt.order.order_expiry = alt.order.order_expiry.wrapping_add(1);
1160            }
1161            CreateOrderField::IntegratorAccountIndex => {
1162                alt.attributes.integrator_account_index =
1163                    alt.attributes.integrator_account_index.wrapping_add(1);
1164            }
1165            CreateOrderField::IntegratorTakerFee => {
1166                alt.attributes.integrator_taker_fee =
1167                    alt.attributes.integrator_taker_fee.wrapping_add(1);
1168            }
1169            CreateOrderField::IntegratorMakerFee => {
1170                alt.attributes.integrator_maker_fee =
1171                    alt.attributes.integrator_maker_fee.wrapping_add(1);
1172            }
1173            CreateOrderField::SkipNonce => {
1174                alt.attributes.skip_nonce = alt.attributes.skip_nonce.wrapping_add(1);
1175            }
1176        }
1177        (alt, chain)
1178    }
1179
1180    proptest! {
1181        /// Mutating any single body, attribute, or context field changes the
1182        /// signed *preimage* (`hash_elements` plus `attributes()` for
1183        /// attribute fields). Pins body-element ordering and attribute-slot
1184        /// participation deterministically - a regression that drops or
1185        /// swaps a field makes at least one mutation a no-op on the
1186        /// preimage. Asserting on the preimage rather than the digest
1187        /// avoids the hash-collision overreach (Poseidon compresses to
1188        /// ~320 bits, so universal-distinctness on digests is not a
1189        /// primitive contract).
1190        #[rstest]
1191        fn prop_field_change_changes_preimage(
1192            base in arb_create_order(),
1193            chain_id in any::<u32>(),
1194            field in arb_create_order_field(),
1195        ) {
1196            let (alt, chain) = mutate_create_order_field(base, chain_id, field);
1197            let base_preimage = (base.hash_elements(chain_id), base.attributes());
1198            let alt_preimage = (alt.hash_elements(chain), alt.attributes());
1199            prop_assert_ne!(
1200                base_preimage,
1201                alt_preimage,
1202                "mutation {:?} did not change preimage",
1203                field,
1204            );
1205        }
1206
1207        /// `compute_tx_hash` matches the explicit branch formula derived
1208        /// from `hash_elements` and `normalized_pairs`. Pins both branches
1209        /// (empty / non-empty attributes) and the branch selector
1210        /// (`L2TxAttributes::is_empty`) deterministically.
1211        #[rstest]
1212        fn prop_compute_tx_hash_matches_explicit_formula(
1213            tx in arb_create_order(),
1214            chain_id in any::<u32>(),
1215        ) {
1216            prop_assert_eq!(compute_tx_hash(&tx, chain_id), explicit_tx_hash(&tx, chain_id));
1217        }
1218
1219        /// `sign_tx` followed by `verify` succeeds for any well-formed
1220        /// CreateOrder body and any non-zero canonical nonce / secret key.
1221        #[rstest]
1222        fn prop_sign_then_verify_for_arbitrary_tx(
1223            tx in arb_create_order(),
1224            chain_id in any::<u32>(),
1225            sk in arb_scalar_nonzero(),
1226            k in arb_scalar_nonzero(),
1227        ) {
1228            let private_key = PrivateKey::from_scalar(sk);
1229            let pk = private_key.public_key();
1230            let signed = sign_tx(&tx, chain_id, &private_key, k);
1231
1232            // Recomputing the hash through `compute_tx_hash` must match
1233            // the value returned by `sign_tx`.
1234            prop_assert_eq!(signed.tx_hash, compute_tx_hash(&tx, chain_id));
1235
1236            let hashed = Fp5::try_from_le_bytes(signed.tx_hash)
1237                .expect("tx hash must encode a canonical Fp5");
1238            prop_assert!(pk.verify(hashed, &signed.sig));
1239        }
1240    }
1241
1242    #[rstest]
1243    fn cancel_order_json_emits_skip_nonce_only_attribute() {
1244        // Synthesized case: skip_nonce=1, no integrator slots
1245        let tx = CancelOrderTxInfo {
1246            context: TxContext {
1247                account_index: 1,
1248                api_key_index: 0,
1249                nonce: 0,
1250                expired_at: 0,
1251            },
1252            market_index: 0,
1253            index: 1,
1254            skip_nonce: 1,
1255        };
1256        let sk = PrivateKey::from_le_bytes_reduce([0x42; SCALAR_BYTES]);
1257        let signed = sign_tx(&tx, 300, &sk, Scalar::ONE);
1258        let json = TxInfoJson::cancel_order(&tx, &signed);
1259        assert!(
1260            json.ends_with(",\"L2TxAttributes\":{\"4\":1}}"),
1261            "was {json}"
1262        );
1263    }
1264}