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", 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", 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", 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", 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) {
324 if attrs.is_empty() {
325 out.push_str("\"L2TxAttributes\":null");
326 return;
327 }
328
329 out.push_str("\"L2TxAttributes\":{");
330 let mut first = true;
331 if attrs.integrator_account_index != 0 {
332 write_attr_pair(out, &mut first, "1", attrs.integrator_account_index);
333 }
334
335 if attrs.integrator_taker_fee != 0 {
336 write_attr_pair(out, &mut first, "2", u64::from(attrs.integrator_taker_fee));
337 }
338
339 if attrs.integrator_maker_fee != 0 {
340 write_attr_pair(out, &mut first, "3", u64::from(attrs.integrator_maker_fee));
341 }
342
343 if attrs.skip_nonce != 0 {
344 write_attr_pair(out, &mut first, "4", u64::from(attrs.skip_nonce));
345 }
346 out.push('}');
347}
348
349fn write_attributes_skip_nonce_only(out: &mut String, attrs: &L2TxAttributes) {
353 if attrs.skip_nonce == 0 {
354 out.push_str("\"L2TxAttributes\":null");
355 return;
356 }
357 out.push_str("\"L2TxAttributes\":{\"4\":");
358 write_u64(out, u64::from(attrs.skip_nonce));
359 out.push('}');
360}
361
362fn write_attr_pair(out: &mut String, first: &mut bool, key: &str, value: u64) {
363 if !*first {
364 out.push(',');
365 }
366 *first = false;
367 out.push('"');
368 out.push_str(key);
369 out.push_str("\":");
370 write_u64(out, value);
371}
372
373fn write_kv_i64(out: &mut String, key: &str, value: i64) {
374 out.push('"');
375 out.push_str(key);
376 out.push_str("\":");
377 write_i64(out, value);
378 out.push(',');
379}
380
381fn write_kv_u64(out: &mut String, key: &str, value: u64) {
382 out.push('"');
383 out.push_str(key);
384 out.push_str("\":");
385 write_u64(out, value);
386 out.push(',');
387}
388
389fn write_i64(out: &mut String, value: i64) {
390 write!(out, "{value}").expect("writing into String never fails");
391}
392
393fn write_u64(out: &mut String, value: u64) {
394 write!(out, "{value}").expect("writing into String never fails");
395}
396
397#[cfg(test)]
398mod tests {
399 use proptest::prelude::*;
400 use rstest::rstest;
401 use serde::Deserialize;
402
403 use super::*;
404 use crate::signing::{
405 curve::{SCALAR_BYTES, Scalar},
406 field::Fp,
407 fixtures::{arb_scalar_nonzero, bytes_to_hex, decode_scalar_bytes, hex_to_bytes},
408 tx::types::{NB_ATTRIBUTES_PER_TX, OrderInfo, TxContext},
409 };
410
411 const ORACLE_JSON: &str = include_str!(concat!(
412 env!("CARGO_MANIFEST_DIR"),
413 "/test_data/signing_tx_oracle.json",
414 ));
415
416 #[derive(Debug, Deserialize)]
417 struct OracleFile {
418 vectors: Vec<OracleVector>,
419 }
420
421 #[derive(Debug, Deserialize)]
422 struct OracleVector {
423 kind: String,
424 chain_id: u32,
425 sk: String,
426 account_index: i64,
427 api_key_index: u8,
428 nonce: i64,
429 expired_at: i64,
430 fields: serde_json::Value,
431 tx_type: u8,
432 tx_info: String,
433 tx_hash: String,
434 sig: String,
435 }
436
437 fn ctx_for(v: &OracleVector) -> TxContext {
438 TxContext {
439 account_index: v.account_index,
440 api_key_index: v.api_key_index,
441 nonce: v.nonce,
442 expired_at: v.expired_at,
443 }
444 }
445
446 fn attrs_from(fields: &serde_json::Value) -> L2TxAttributes {
447 L2TxAttributes {
448 integrator_account_index: fields["integrator_account_index"].as_u64().unwrap_or(0),
449 integrator_taker_fee: fields["integrator_taker_fee"].as_u64().unwrap_or(0) as u32,
450 integrator_maker_fee: fields["integrator_maker_fee"].as_u64().unwrap_or(0) as u32,
451 skip_nonce: fields["skip_nonce"].as_u64().unwrap_or(0) as u8,
452 }
453 }
454
455 fn expect_create_order(v: &OracleVector) -> CreateOrderTxInfo {
456 let f = &v.fields;
457 CreateOrderTxInfo {
458 context: ctx_for(v),
459 order: OrderInfo {
460 market_index: f["market_index"].as_i64().unwrap(),
461 client_order_index: f["client_order_index"].as_i64().unwrap(),
462 base_amount: f["base_amount"].as_i64().unwrap(),
463 price: f["price"].as_u64().unwrap() as u32,
464 is_ask: f["is_ask"].as_bool().unwrap(),
465 order_type: f["order_type"].as_u64().unwrap() as u8,
466 time_in_force: f["time_in_force"].as_u64().unwrap() as u8,
467 reduce_only: f["reduce_only"].as_bool().unwrap(),
468 trigger_price: f["trigger_price"].as_u64().unwrap() as u32,
469 order_expiry: f["order_expiry"].as_i64().unwrap(),
470 },
471 attributes: attrs_from(f),
472 }
473 }
474
475 fn expect_cancel_order(v: &OracleVector) -> CancelOrderTxInfo {
476 let f = &v.fields;
477 CancelOrderTxInfo {
478 context: ctx_for(v),
479 market_index: f["market_index"].as_i64().unwrap(),
480 index: f["index"].as_i64().unwrap(),
481 skip_nonce: f["skip_nonce"].as_u64().unwrap_or(0) as u8,
482 }
483 }
484
485 fn expect_modify_order(v: &OracleVector) -> ModifyOrderTxInfo {
486 let f = &v.fields;
487 ModifyOrderTxInfo {
488 context: ctx_for(v),
489 market_index: f["market_index"].as_i64().unwrap(),
490 index: f["index"].as_i64().unwrap(),
491 base_amount: f["base_amount"].as_i64().unwrap(),
492 price: f["price"].as_u64().unwrap() as u32,
493 trigger_price: f["trigger_price"].as_u64().unwrap() as u32,
494 attributes: attrs_from(f),
495 }
496 }
497
498 fn expect_cancel_all_orders(v: &OracleVector) -> CancelAllOrdersTxInfo {
499 let f = &v.fields;
500 CancelAllOrdersTxInfo {
501 context: ctx_for(v),
502 time_in_force: f["time_in_force"].as_u64().unwrap() as u8,
503 scheduled_time_ms: f["scheduled_time_ms"].as_i64().unwrap(),
504 skip_nonce: f["skip_nonce"].as_u64().unwrap_or(0) as u8,
505 }
506 }
507
508 fn expect_update_leverage(v: &OracleVector) -> UpdateLeverageTxInfo {
509 let f = &v.fields;
510 UpdateLeverageTxInfo {
511 context: ctx_for(v),
512 market_index: f["market_index"].as_i64().unwrap(),
513 initial_margin_fraction: f["initial_margin_fraction"].as_u64().unwrap() as u16,
514 margin_mode: f["margin_mode"].as_u64().unwrap() as u8,
515 skip_nonce: f["skip_nonce"].as_u64().unwrap_or(0) as u8,
516 }
517 }
518
519 fn expect_approve_integrator(v: &OracleVector) -> ApproveIntegratorTxInfo {
520 let f = &v.fields;
521 ApproveIntegratorTxInfo {
522 context: ctx_for(v),
523 integrator_account_index: f["integrator_account_index"].as_i64().unwrap(),
524 max_perps_taker_fee: f["max_perps_taker_fee"].as_u64().unwrap() as u32,
525 max_perps_maker_fee: f["max_perps_maker_fee"].as_u64().unwrap() as u32,
526 max_spot_taker_fee: f["max_spot_taker_fee"].as_u64().unwrap() as u32,
527 max_spot_maker_fee: f["max_spot_maker_fee"].as_u64().unwrap() as u32,
528 approval_expiry: f["approval_expiry"].as_i64().unwrap(),
529 skip_nonce: f["skip_nonce"].as_u64().unwrap_or(0) as u8,
530 }
531 }
532
533 fn assert_hash_matches<T: LighterTx>(tx: &T, v: &OracleVector) {
534 let got = compute_tx_hash(tx, v.chain_id);
535 assert_eq!(
536 bytes_to_hex(&got),
537 v.tx_hash,
538 "{}: tx_hash diverged",
539 v.kind,
540 );
541 }
542
543 fn assert_oracle_sig_verifies<T: LighterTx>(tx: &T, v: &OracleVector) {
544 let sig_bytes = hex_to_bytes(&v.sig);
545 assert_eq!(sig_bytes.len(), SIG_BYTES);
546 let mut buf = [0u8; SIG_BYTES];
547 buf.copy_from_slice(&sig_bytes);
548 let sig = Signature::from_le_bytes_reduce(buf);
549
550 let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
551 let pk = sk.public_key();
552 let tx_hash = compute_tx_hash(tx, v.chain_id);
553 let hashed = Fp5::try_from_le_bytes(tx_hash).expect("oracle hash must be canonical");
554
555 assert!(
556 pk.verify(hashed, &sig),
557 "{}: oracle sig must verify against the recomputed hash",
558 v.kind,
559 );
560 }
561
562 fn assert_round_trip_sign<T: LighterTx>(tx: &T, v: &OracleVector) {
563 let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
564 let mut k_bytes = decode_scalar_bytes(&v.sk);
569 k_bytes[0] ^= 0x01;
570 let k = Scalar::from_le_bytes_reduce(k_bytes);
571 assert!(!k.is_zero(), "{}: derived k must be non-zero", v.kind);
572 assert!(k.is_canonical(), "{}: derived k must be canonical", v.kind,);
573
574 let signed = sign_tx(tx, v.chain_id, &sk, k);
575 assert_eq!(
576 bytes_to_hex(&signed.tx_hash),
577 v.tx_hash,
578 "{}: sign_tx tx_hash diverged",
579 v.kind,
580 );
581 assert_eq!(
582 signed.tx_hash_hex(),
583 v.tx_hash,
584 "{}: tx_hash_hex must render the venue's lowercase hex form",
585 v.kind,
586 );
587 let pk = sk.public_key();
588 let hashed = Fp5::try_from_le_bytes(signed.tx_hash).unwrap();
589 assert!(
590 pk.verify(hashed, &signed.sig),
591 "{}: round-trip sig must verify",
592 v.kind,
593 );
594 }
595
596 #[rstest]
597 fn oracle_tx_hash_matches_create_order() {
598 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
599 for v in suite.vectors.iter().filter(|v| v.kind == "create_order") {
600 assert_eq!(v.tx_type, 14);
601 let tx = expect_create_order(v);
602 assert_hash_matches(&tx, v);
603 assert_oracle_sig_verifies(&tx, v);
604 assert_round_trip_sign(&tx, v);
605 }
606 }
607
608 #[rstest]
609 fn oracle_tx_hash_matches_cancel_order() {
610 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
611 for v in suite.vectors.iter().filter(|v| v.kind == "cancel_order") {
612 assert_eq!(v.tx_type, 15);
613 let tx = expect_cancel_order(v);
614 assert_hash_matches(&tx, v);
615 assert_oracle_sig_verifies(&tx, v);
616 assert_round_trip_sign(&tx, v);
617 }
618 }
619
620 #[rstest]
621 fn oracle_tx_hash_matches_modify_order() {
622 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
623 for v in suite.vectors.iter().filter(|v| v.kind == "modify_order") {
624 assert_eq!(v.tx_type, 17);
625 let tx = expect_modify_order(v);
626 assert_hash_matches(&tx, v);
627 assert_oracle_sig_verifies(&tx, v);
628 assert_round_trip_sign(&tx, v);
629 }
630 }
631
632 #[rstest]
633 fn oracle_tx_hash_matches_cancel_all_orders() {
634 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
635 for v in suite
636 .vectors
637 .iter()
638 .filter(|v| v.kind == "cancel_all_orders")
639 {
640 assert_eq!(v.tx_type, 16);
641 let tx = expect_cancel_all_orders(v);
642 assert_hash_matches(&tx, v);
643 assert_oracle_sig_verifies(&tx, v);
644 assert_round_trip_sign(&tx, v);
645 }
646 }
647
648 #[rstest]
649 fn oracle_tx_hash_matches_update_leverage() {
650 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
651 for v in suite.vectors.iter().filter(|v| v.kind == "update_leverage") {
652 assert_eq!(v.tx_type, 20);
653 let tx = expect_update_leverage(v);
654 assert_hash_matches(&tx, v);
655 assert_oracle_sig_verifies(&tx, v);
656 assert_round_trip_sign(&tx, v);
657 }
658 }
659
660 #[rstest]
661 fn oracle_tx_hash_matches_approve_integrator() {
662 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
663 for v in suite
664 .vectors
665 .iter()
666 .filter(|v| v.kind == "approve_integrator")
667 {
668 assert_eq!(v.tx_type, 45);
669 let tx = expect_approve_integrator(v);
670 assert_hash_matches(&tx, v);
671 assert_oracle_sig_verifies(&tx, v);
672 assert_round_trip_sign(&tx, v);
673 }
674 }
675
676 fn redact_sig(json: &str) -> String {
680 let start = json.find("\"Sig\":\"").expect("Sig key missing");
681 let after_open = start + "\"Sig\":\"".len();
682 let close = json[after_open..]
683 .find('"')
684 .map(|i| after_open + i)
685 .expect("Sig value not closed");
686 let mut out = String::with_capacity(json.len());
687 out.push_str(&json[..after_open]);
688 out.push_str("REDACTED");
689 out.push_str(&json[close..]);
690 out
691 }
692
693 fn signed_with_fixture_k(
694 v: &OracleVector,
695 sk: &PrivateKey,
696 signed_tx: impl Fn(Scalar) -> SignedTx,
697 ) -> SignedTx {
698 let _ = sk;
699 let mut k_bytes = decode_scalar_bytes(&v.sk);
700 k_bytes[0] ^= 0x01;
701 signed_tx(Scalar::from_le_bytes_reduce(k_bytes))
702 }
703
704 #[rstest]
705 fn create_order_json_byte_equals_oracle_modulo_sig() {
706 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
707 for v in suite.vectors.iter().filter(|v| v.kind == "create_order") {
708 let tx = expect_create_order(v);
709 let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
710 let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
711 let json = TxInfoJson::create_order(&tx, &signed);
712 assert_eq!(
713 redact_sig(&json),
714 redact_sig(&v.tx_info),
715 "create_order tx_info diverged",
716 );
717 }
718 }
719
720 #[rstest]
721 #[case(2)]
722 #[case(3)]
723 #[case(4)]
724 #[case(5)]
725 fn oracle_covers_conditional_create_order_type(#[case] order_type: u64) {
726 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
727 assert!(suite.vectors.iter().any(|v| {
728 v.kind == "create_order" && v.fields["order_type"].as_u64() == Some(order_type)
729 }));
730 }
731
732 #[rstest]
733 #[case("create_order")]
734 #[case("modify_order")]
735 fn oracle_covers_production_integrator_attributes(#[case] kind: &str) {
736 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
737 assert!(suite.vectors.iter().any(|v| {
738 v.kind == kind
739 && v.fields["integrator_account_index"].as_u64() == Some(723_813)
740 && v.fields["integrator_taker_fee"].as_u64() == Some(0)
741 && v.fields["integrator_maker_fee"].as_u64() == Some(0)
742 }));
743 }
744
745 #[rstest]
746 fn cancel_order_json_emits_null_attributes_when_empty() {
747 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
748 for v in suite.vectors.iter().filter(|v| v.kind == "cancel_order") {
749 let tx = expect_cancel_order(v);
750 let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
751 let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
752 let json = TxInfoJson::cancel_order(&tx, &signed);
753 assert_eq!(
754 redact_sig(&json),
755 redact_sig(&v.tx_info),
756 "cancel_order tx_info diverged",
757 );
758 }
759 }
760
761 #[rstest]
762 fn modify_order_json_byte_equals_oracle_modulo_sig() {
763 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
764 for v in suite.vectors.iter().filter(|v| v.kind == "modify_order") {
765 let tx = expect_modify_order(v);
766 let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
767 let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
768 let json = TxInfoJson::modify_order(&tx, &signed);
769 assert_eq!(
770 redact_sig(&json),
771 redact_sig(&v.tx_info),
772 "modify_order tx_info diverged",
773 );
774 }
775 }
776
777 fn stub_signed() -> SignedTx {
778 SignedTx {
779 tx_hash: [0u8; TX_HASH_BYTES],
780 sig: Signature {
781 s: Scalar::from_le_bytes_reduce([0u8; SCALAR_BYTES]),
782 e: Scalar::from_le_bytes_reduce([0u8; SCALAR_BYTES]),
783 },
784 sig_bytes: [0u8; SIG_BYTES],
785 }
786 }
787
788 fn stub_context() -> TxContext {
789 TxContext {
790 account_index: 12_345,
791 api_key_index: 5,
792 nonce: 7,
793 expired_at: 1_777_804_395_089,
794 }
795 }
796
797 #[rstest]
798 fn cancel_all_orders_json_byte_equals_oracle_modulo_sig() {
799 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
800 for v in suite
801 .vectors
802 .iter()
803 .filter(|v| v.kind == "cancel_all_orders")
804 {
805 let tx = expect_cancel_all_orders(v);
806 let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
807 let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
808 let json = TxInfoJson::cancel_all_orders(&tx, &signed);
809 assert_eq!(
810 redact_sig(&json),
811 redact_sig(&v.tx_info),
812 "cancel_all_orders tx_info diverged",
813 );
814 }
815 }
816
817 #[rstest]
818 fn tx_info_json_renders_widened_market_index_as_number() {
819 let order = OrderInfo {
820 market_index: 40_000,
821 client_order_index: 123,
822 base_amount: 1_000,
823 price: 405_000,
824 is_ask: true,
825 order_type: 0,
826 time_in_force: 1,
827 reduce_only: false,
828 trigger_price: 0,
829 order_expiry: 1_735_689_600_000,
830 };
831
832 let create = CreateOrderTxInfo {
833 context: stub_context(),
834 order,
835 attributes: L2TxAttributes::default(),
836 };
837
838 let cancel = CancelOrderTxInfo {
839 context: stub_context(),
840 market_index: 40_000,
841 index: 123,
842 skip_nonce: 0,
843 };
844
845 let modify = ModifyOrderTxInfo {
846 context: stub_context(),
847 market_index: 40_000,
848 index: 123,
849 base_amount: 1_100,
850 price: 410_000,
851 trigger_price: 0,
852 attributes: L2TxAttributes::default(),
853 };
854
855 for json in [
856 TxInfoJson::create_order(&create, &stub_signed()),
857 TxInfoJson::cancel_order(&cancel, &stub_signed()),
858 TxInfoJson::modify_order(&modify, &stub_signed()),
859 ] {
860 assert!(
861 json.contains(r#""MarketIndex":40000"#),
862 "widened MarketIndex must render unquoted, was {json}",
863 );
864 }
865 }
866
867 #[rstest]
868 fn cancel_all_orders_json_emits_skip_nonce_attr_when_set() {
869 let tx = CancelAllOrdersTxInfo {
870 context: stub_context(),
871 time_in_force: 1, scheduled_time_ms: 1_800_000_000_000,
873 skip_nonce: 1,
874 };
875 let json = TxInfoJson::cancel_all_orders(&tx, &stub_signed());
876 let expected = concat!(
877 r#"{"AccountIndex":12345,"ApiKeyIndex":5,"#,
878 r#""TimeInForce":1,"Time":1800000000000,"#,
879 r#""ExpiredAt":1777804395089,"Nonce":7,"#,
880 r#""Sig":"REDACTED","L2TxAttributes":{"4":1}}"#,
881 );
882 assert_eq!(redact_sig(&json), expected);
883 }
884
885 #[rstest]
886 fn update_leverage_json_byte_equals_oracle_modulo_sig() {
887 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
888 for v in suite.vectors.iter().filter(|v| v.kind == "update_leverage") {
889 let tx = expect_update_leverage(v);
890 let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
891 let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
892 let json = TxInfoJson::update_leverage(&tx, &signed);
893 assert_eq!(
894 redact_sig(&json),
895 redact_sig(&v.tx_info),
896 "update_leverage tx_info diverged",
897 );
898 }
899 }
900
901 #[rstest]
902 fn approve_integrator_json_byte_equals_oracle_modulo_sig() {
903 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
904 for v in suite
905 .vectors
906 .iter()
907 .filter(|v| v.kind == "approve_integrator")
908 {
909 let tx = expect_approve_integrator(v);
910 let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
911 let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
912 let json = TxInfoJson::approve_integrator(&tx, &signed, "");
913 assert_eq!(
914 redact_sig(&json),
915 redact_sig(&v.tx_info),
916 "approve_integrator tx_info diverged",
917 );
918 }
919 }
920
921 fn arb_tx_context() -> impl Strategy<Value = TxContext> {
922 (any::<i64>(), any::<u8>(), any::<i64>(), any::<i64>()).prop_map(
923 |(account_index, api_key_index, nonce, expired_at)| TxContext {
924 account_index,
925 api_key_index,
926 nonce,
927 expired_at,
928 },
929 )
930 }
931
932 fn arb_order_info() -> impl Strategy<Value = OrderInfo> {
933 (
934 any::<i64>(),
935 any::<i64>(),
936 any::<i64>(),
937 any::<u32>(),
938 any::<bool>(),
939 any::<u8>(),
940 any::<u8>(),
941 any::<bool>(),
942 any::<u32>(),
943 any::<i64>(),
944 )
945 .prop_map(
946 |(
947 market_index,
948 client_order_index,
949 base_amount,
950 price,
951 is_ask,
952 order_type,
953 time_in_force,
954 reduce_only,
955 trigger_price,
956 order_expiry,
957 )| OrderInfo {
958 market_index,
959 client_order_index,
960 base_amount,
961 price,
962 is_ask,
963 order_type,
964 time_in_force,
965 reduce_only,
966 trigger_price,
967 order_expiry,
968 },
969 )
970 }
971
972 fn arb_l2_attributes() -> impl Strategy<Value = L2TxAttributes> {
973 (any::<u64>(), any::<u32>(), any::<u32>(), any::<u8>()).prop_map(
974 |(integrator_account_index, integrator_taker_fee, integrator_maker_fee, skip_nonce)| {
975 L2TxAttributes {
976 integrator_account_index,
977 integrator_taker_fee,
978 integrator_maker_fee,
979 skip_nonce,
980 }
981 },
982 )
983 }
984
985 fn arb_create_order() -> impl Strategy<Value = CreateOrderTxInfo> {
986 (arb_tx_context(), arb_order_info(), arb_l2_attributes()).prop_map(
987 |(context, order, attributes)| CreateOrderTxInfo {
988 context,
989 order,
990 attributes,
991 },
992 )
993 }
994
995 proptest! {
996 #[rstest]
998 fn prop_compute_tx_hash_deterministic(tx in arb_create_order(), chain_id in any::<u32>()) {
999 prop_assert_eq!(compute_tx_hash(&tx, chain_id), compute_tx_hash(&tx, chain_id));
1000 }
1001
1002 #[rstest]
1006 fn prop_empty_attrs_branch_uses_body_hash_only(
1007 mut tx in arb_create_order(),
1008 chain_id in any::<u32>(),
1009 ) {
1010 tx.attributes = L2TxAttributes::default();
1011 let from_pipeline = compute_tx_hash(&tx, chain_id);
1012 let from_body =
1013 hash_to_quintic_extension(&tx.hash_elements(chain_id)).to_le_bytes();
1014 prop_assert_eq!(from_pipeline, from_body);
1015 }
1016
1017 #[rstest]
1023 fn prop_non_empty_attrs_matches_aggregation_formula(
1024 mut tx in arb_create_order(),
1025 chain_id in any::<u32>(),
1026 ) {
1027 tx.attributes = L2TxAttributes {
1030 integrator_account_index: 0,
1031 integrator_taker_fee: 0,
1032 integrator_maker_fee: 0,
1033 skip_nonce: 1,
1034 };
1035 let from_pipeline = compute_tx_hash(&tx, chain_id);
1036 let expected = explicit_tx_hash(&tx, chain_id);
1037 prop_assert_eq!(from_pipeline, expected);
1038 }
1039 }
1040
1041 fn explicit_tx_hash<T: LighterTx>(tx: &T, chain_id: u32) -> [u8; TX_HASH_BYTES] {
1047 let body_elems = tx.hash_elements(chain_id);
1048 let body_digest = hash_to_quintic_extension(&body_elems);
1049 let attrs = tx.attributes();
1050 let result = if attrs.is_empty() {
1051 body_digest
1052 } else {
1053 let pairs = attrs.normalized_pairs();
1054 let mut elems = [Fp::from_u64_reduce(0); NB_ATTRIBUTES_PER_TX * 2];
1055 for (i, (ty, val)) in pairs.iter().enumerate() {
1056 elems[i * 2] = Fp::from_u64_reduce(u64::from(*ty));
1057 elems[i * 2 + 1] = Fp::from_u64_reduce(*val);
1058 }
1059 let attr_digest = hash_to_quintic_extension(&elems);
1060 hash_two_to_quintic(body_digest, attr_digest)
1061 };
1062 result.to_le_bytes()
1063 }
1064
1065 #[derive(Debug, Clone, Copy)]
1069 enum CreateOrderField {
1070 ChainId,
1071 AccountIndex,
1072 ApiKeyIndex,
1073 Nonce,
1074 ExpiredAt,
1075 MarketIndex,
1076 ClientOrderIndex,
1077 BaseAmount,
1078 Price,
1079 IsAsk,
1080 OrderType,
1081 TimeInForce,
1082 ReduceOnly,
1083 TriggerPrice,
1084 OrderExpiry,
1085 IntegratorAccountIndex,
1086 IntegratorTakerFee,
1087 IntegratorMakerFee,
1088 SkipNonce,
1089 }
1090
1091 fn arb_create_order_field() -> impl Strategy<Value = CreateOrderField> {
1092 prop_oneof![
1093 Just(CreateOrderField::ChainId),
1094 Just(CreateOrderField::AccountIndex),
1095 Just(CreateOrderField::ApiKeyIndex),
1096 Just(CreateOrderField::Nonce),
1097 Just(CreateOrderField::ExpiredAt),
1098 Just(CreateOrderField::MarketIndex),
1099 Just(CreateOrderField::ClientOrderIndex),
1100 Just(CreateOrderField::BaseAmount),
1101 Just(CreateOrderField::Price),
1102 Just(CreateOrderField::IsAsk),
1103 Just(CreateOrderField::OrderType),
1104 Just(CreateOrderField::TimeInForce),
1105 Just(CreateOrderField::ReduceOnly),
1106 Just(CreateOrderField::TriggerPrice),
1107 Just(CreateOrderField::OrderExpiry),
1108 Just(CreateOrderField::IntegratorAccountIndex),
1109 Just(CreateOrderField::IntegratorTakerFee),
1110 Just(CreateOrderField::IntegratorMakerFee),
1111 Just(CreateOrderField::SkipNonce),
1112 ]
1113 }
1114
1115 fn mutate_create_order_field(
1119 base: CreateOrderTxInfo,
1120 chain_id: u32,
1121 field: CreateOrderField,
1122 ) -> (CreateOrderTxInfo, u32) {
1123 let mut alt = base;
1124 let mut chain = chain_id;
1125 match field {
1126 CreateOrderField::ChainId => chain = chain.wrapping_add(1),
1127 CreateOrderField::AccountIndex => {
1128 alt.context.account_index = alt.context.account_index.wrapping_add(1);
1129 }
1130 CreateOrderField::ApiKeyIndex => {
1131 alt.context.api_key_index = alt.context.api_key_index.wrapping_add(1);
1132 }
1133 CreateOrderField::Nonce => alt.context.nonce = alt.context.nonce.wrapping_add(1),
1134 CreateOrderField::ExpiredAt => {
1135 alt.context.expired_at = alt.context.expired_at.wrapping_add(1);
1136 }
1137 CreateOrderField::MarketIndex => {
1138 alt.order.market_index = alt.order.market_index.wrapping_add(1);
1139 }
1140 CreateOrderField::ClientOrderIndex => {
1141 alt.order.client_order_index = alt.order.client_order_index.wrapping_add(1);
1142 }
1143 CreateOrderField::BaseAmount => {
1144 alt.order.base_amount = alt.order.base_amount.wrapping_add(1);
1145 }
1146 CreateOrderField::Price => alt.order.price = alt.order.price.wrapping_add(1),
1147 CreateOrderField::IsAsk => alt.order.is_ask = !alt.order.is_ask,
1148 CreateOrderField::OrderType => {
1149 alt.order.order_type = alt.order.order_type.wrapping_add(1);
1150 }
1151 CreateOrderField::TimeInForce => {
1152 alt.order.time_in_force = alt.order.time_in_force.wrapping_add(1);
1153 }
1154 CreateOrderField::ReduceOnly => alt.order.reduce_only = !alt.order.reduce_only,
1155 CreateOrderField::TriggerPrice => {
1156 alt.order.trigger_price = alt.order.trigger_price.wrapping_add(1);
1157 }
1158 CreateOrderField::OrderExpiry => {
1159 alt.order.order_expiry = alt.order.order_expiry.wrapping_add(1);
1160 }
1161 CreateOrderField::IntegratorAccountIndex => {
1162 alt.attributes.integrator_account_index =
1163 alt.attributes.integrator_account_index.wrapping_add(1);
1164 }
1165 CreateOrderField::IntegratorTakerFee => {
1166 alt.attributes.integrator_taker_fee =
1167 alt.attributes.integrator_taker_fee.wrapping_add(1);
1168 }
1169 CreateOrderField::IntegratorMakerFee => {
1170 alt.attributes.integrator_maker_fee =
1171 alt.attributes.integrator_maker_fee.wrapping_add(1);
1172 }
1173 CreateOrderField::SkipNonce => {
1174 alt.attributes.skip_nonce = alt.attributes.skip_nonce.wrapping_add(1);
1175 }
1176 }
1177 (alt, chain)
1178 }
1179
1180 proptest! {
1181 #[rstest]
1191 fn prop_field_change_changes_preimage(
1192 base in arb_create_order(),
1193 chain_id in any::<u32>(),
1194 field in arb_create_order_field(),
1195 ) {
1196 let (alt, chain) = mutate_create_order_field(base, chain_id, field);
1197 let base_preimage = (base.hash_elements(chain_id), base.attributes());
1198 let alt_preimage = (alt.hash_elements(chain), alt.attributes());
1199 prop_assert_ne!(
1200 base_preimage,
1201 alt_preimage,
1202 "mutation {:?} did not change preimage",
1203 field,
1204 );
1205 }
1206
1207 #[rstest]
1212 fn prop_compute_tx_hash_matches_explicit_formula(
1213 tx in arb_create_order(),
1214 chain_id in any::<u32>(),
1215 ) {
1216 prop_assert_eq!(compute_tx_hash(&tx, chain_id), explicit_tx_hash(&tx, chain_id));
1217 }
1218
1219 #[rstest]
1222 fn prop_sign_then_verify_for_arbitrary_tx(
1223 tx in arb_create_order(),
1224 chain_id in any::<u32>(),
1225 sk in arb_scalar_nonzero(),
1226 k in arb_scalar_nonzero(),
1227 ) {
1228 let private_key = PrivateKey::from_scalar(sk);
1229 let pk = private_key.public_key();
1230 let signed = sign_tx(&tx, chain_id, &private_key, k);
1231
1232 prop_assert_eq!(signed.tx_hash, compute_tx_hash(&tx, chain_id));
1235
1236 let hashed = Fp5::try_from_le_bytes(signed.tx_hash)
1237 .expect("tx hash must encode a canonical Fp5");
1238 prop_assert!(pk.verify(hashed, &signed.sig));
1239 }
1240 }
1241
1242 #[rstest]
1243 fn cancel_order_json_emits_skip_nonce_only_attribute() {
1244 let tx = CancelOrderTxInfo {
1246 context: TxContext {
1247 account_index: 1,
1248 api_key_index: 0,
1249 nonce: 0,
1250 expired_at: 0,
1251 },
1252 market_index: 0,
1253 index: 1,
1254 skip_nonce: 1,
1255 };
1256 let sk = PrivateKey::from_le_bytes_reduce([0x42; SCALAR_BYTES]);
1257 let signed = sign_tx(&tx, 300, &sk, Scalar::ONE);
1258 let json = TxInfoJson::cancel_order(&tx, &signed);
1259 assert!(
1260 json.ends_with(",\"L2TxAttributes\":{\"4\":1}}"),
1261 "was {json}"
1262 );
1263 }
1264}