Skip to main content

nautilus_lighter/signing/tx/
encode.rs

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