1use 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
52pub const TX_HASH_BYTES: usize = 40;
54
55pub 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
82fn 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
96pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
124pub struct SignedTx {
125 pub tx_hash: [u8; TX_HASH_BYTES],
127 pub sig: Signature,
129 pub sig_bytes: [u8; SIG_BYTES],
131}
132
133impl SignedTx {
134 #[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#[derive(Debug)]
151pub struct TxInfoJson;
152
153impl TxInfoJson {
154 #[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 ); 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 #[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 #[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 #[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 #[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 #[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 #[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
353fn 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
398fn 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 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 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_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 #[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 #[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 #[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 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 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 #[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 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 #[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 #[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 #[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 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 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}