1use 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
52pub const TX_HASH_BYTES: usize = 40;
54
55#[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
79fn 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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
119pub struct SignedTx {
120 pub tx_hash: [u8; TX_HASH_BYTES],
122 pub sig: Signature,
124 pub sig_bytes: [u8; SIG_BYTES],
126}
127
128impl SignedTx {
129 #[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#[derive(Debug)]
147pub struct TxInfoJson;
148
149impl TxInfoJson {
150 #[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 ); 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 #[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 #[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 #[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 #[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 #[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
321fn 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
334fn 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 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 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 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_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 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 #[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 #[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 #[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 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 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 #[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 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 #[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 #[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 #[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 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 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}