Skip to main content

nautilus_lighter/signing/tx/
encode.rs

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