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) {
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() as i16,
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() as i16,
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() as i16,
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() as i16,
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 cancel_all_orders_json_emits_skip_nonce_attr_when_set() {
819 let tx = CancelAllOrdersTxInfo {
820 context: stub_context(),
821 time_in_force: 1, scheduled_time_ms: 1_800_000_000_000,
823 skip_nonce: 1,
824 };
825 let json = TxInfoJson::cancel_all_orders(&tx, &stub_signed());
826 let expected = concat!(
827 r#"{"AccountIndex":12345,"ApiKeyIndex":5,"#,
828 r#""TimeInForce":1,"Time":1800000000000,"#,
829 r#""ExpiredAt":1777804395089,"Nonce":7,"#,
830 r#""Sig":"REDACTED","L2TxAttributes":{"4":1}}"#,
831 );
832 assert_eq!(redact_sig(&json), expected);
833 }
834
835 #[rstest]
836 fn update_leverage_json_byte_equals_oracle_modulo_sig() {
837 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
838 for v in suite.vectors.iter().filter(|v| v.kind == "update_leverage") {
839 let tx = expect_update_leverage(v);
840 let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
841 let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
842 let json = TxInfoJson::update_leverage(&tx, &signed);
843 assert_eq!(
844 redact_sig(&json),
845 redact_sig(&v.tx_info),
846 "update_leverage tx_info diverged",
847 );
848 }
849 }
850
851 #[rstest]
852 fn approve_integrator_json_byte_equals_oracle_modulo_sig() {
853 let suite: OracleFile = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
854 for v in suite
855 .vectors
856 .iter()
857 .filter(|v| v.kind == "approve_integrator")
858 {
859 let tx = expect_approve_integrator(v);
860 let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
861 let signed = signed_with_fixture_k(v, &sk, |k| sign_tx(&tx, v.chain_id, &sk, k));
862 let json = TxInfoJson::approve_integrator(&tx, &signed, "");
863 assert_eq!(
864 redact_sig(&json),
865 redact_sig(&v.tx_info),
866 "approve_integrator tx_info diverged",
867 );
868 }
869 }
870
871 fn arb_tx_context() -> impl Strategy<Value = TxContext> {
872 (any::<i64>(), any::<u8>(), any::<i64>(), any::<i64>()).prop_map(
873 |(account_index, api_key_index, nonce, expired_at)| TxContext {
874 account_index,
875 api_key_index,
876 nonce,
877 expired_at,
878 },
879 )
880 }
881
882 fn arb_order_info() -> impl Strategy<Value = OrderInfo> {
883 (
884 any::<i16>(),
885 any::<i64>(),
886 any::<i64>(),
887 any::<u32>(),
888 any::<bool>(),
889 any::<u8>(),
890 any::<u8>(),
891 any::<bool>(),
892 any::<u32>(),
893 any::<i64>(),
894 )
895 .prop_map(
896 |(
897 market_index,
898 client_order_index,
899 base_amount,
900 price,
901 is_ask,
902 order_type,
903 time_in_force,
904 reduce_only,
905 trigger_price,
906 order_expiry,
907 )| OrderInfo {
908 market_index,
909 client_order_index,
910 base_amount,
911 price,
912 is_ask,
913 order_type,
914 time_in_force,
915 reduce_only,
916 trigger_price,
917 order_expiry,
918 },
919 )
920 }
921
922 fn arb_l2_attributes() -> impl Strategy<Value = L2TxAttributes> {
923 (any::<u64>(), any::<u32>(), any::<u32>(), any::<u8>()).prop_map(
924 |(integrator_account_index, integrator_taker_fee, integrator_maker_fee, skip_nonce)| {
925 L2TxAttributes {
926 integrator_account_index,
927 integrator_taker_fee,
928 integrator_maker_fee,
929 skip_nonce,
930 }
931 },
932 )
933 }
934
935 fn arb_create_order() -> impl Strategy<Value = CreateOrderTxInfo> {
936 (arb_tx_context(), arb_order_info(), arb_l2_attributes()).prop_map(
937 |(context, order, attributes)| CreateOrderTxInfo {
938 context,
939 order,
940 attributes,
941 },
942 )
943 }
944
945 proptest! {
946 #[rstest]
948 fn prop_compute_tx_hash_deterministic(tx in arb_create_order(), chain_id in any::<u32>()) {
949 prop_assert_eq!(compute_tx_hash(&tx, chain_id), compute_tx_hash(&tx, chain_id));
950 }
951
952 #[rstest]
956 fn prop_empty_attrs_branch_uses_body_hash_only(
957 mut tx in arb_create_order(),
958 chain_id in any::<u32>(),
959 ) {
960 tx.attributes = L2TxAttributes::default();
961 let from_pipeline = compute_tx_hash(&tx, chain_id);
962 let from_body =
963 hash_to_quintic_extension(&tx.hash_elements(chain_id)).to_le_bytes();
964 prop_assert_eq!(from_pipeline, from_body);
965 }
966
967 #[rstest]
973 fn prop_non_empty_attrs_matches_aggregation_formula(
974 mut tx in arb_create_order(),
975 chain_id in any::<u32>(),
976 ) {
977 tx.attributes = L2TxAttributes {
980 integrator_account_index: 0,
981 integrator_taker_fee: 0,
982 integrator_maker_fee: 0,
983 skip_nonce: 1,
984 };
985 let from_pipeline = compute_tx_hash(&tx, chain_id);
986 let expected = explicit_tx_hash(&tx, chain_id);
987 prop_assert_eq!(from_pipeline, expected);
988 }
989 }
990
991 fn explicit_tx_hash<T: LighterTx>(tx: &T, chain_id: u32) -> [u8; TX_HASH_BYTES] {
997 let body_elems = tx.hash_elements(chain_id);
998 let body_digest = hash_to_quintic_extension(&body_elems);
999 let attrs = tx.attributes();
1000 let result = if attrs.is_empty() {
1001 body_digest
1002 } else {
1003 let pairs = attrs.normalized_pairs();
1004 let mut elems = [Fp::from_u64_reduce(0); NB_ATTRIBUTES_PER_TX * 2];
1005 for (i, (ty, val)) in pairs.iter().enumerate() {
1006 elems[i * 2] = Fp::from_u64_reduce(u64::from(*ty));
1007 elems[i * 2 + 1] = Fp::from_u64_reduce(*val);
1008 }
1009 let attr_digest = hash_to_quintic_extension(&elems);
1010 hash_two_to_quintic(body_digest, attr_digest)
1011 };
1012 result.to_le_bytes()
1013 }
1014
1015 #[derive(Debug, Clone, Copy)]
1019 enum CreateOrderField {
1020 ChainId,
1021 AccountIndex,
1022 ApiKeyIndex,
1023 Nonce,
1024 ExpiredAt,
1025 MarketIndex,
1026 ClientOrderIndex,
1027 BaseAmount,
1028 Price,
1029 IsAsk,
1030 OrderType,
1031 TimeInForce,
1032 ReduceOnly,
1033 TriggerPrice,
1034 OrderExpiry,
1035 IntegratorAccountIndex,
1036 IntegratorTakerFee,
1037 IntegratorMakerFee,
1038 SkipNonce,
1039 }
1040
1041 fn arb_create_order_field() -> impl Strategy<Value = CreateOrderField> {
1042 prop_oneof![
1043 Just(CreateOrderField::ChainId),
1044 Just(CreateOrderField::AccountIndex),
1045 Just(CreateOrderField::ApiKeyIndex),
1046 Just(CreateOrderField::Nonce),
1047 Just(CreateOrderField::ExpiredAt),
1048 Just(CreateOrderField::MarketIndex),
1049 Just(CreateOrderField::ClientOrderIndex),
1050 Just(CreateOrderField::BaseAmount),
1051 Just(CreateOrderField::Price),
1052 Just(CreateOrderField::IsAsk),
1053 Just(CreateOrderField::OrderType),
1054 Just(CreateOrderField::TimeInForce),
1055 Just(CreateOrderField::ReduceOnly),
1056 Just(CreateOrderField::TriggerPrice),
1057 Just(CreateOrderField::OrderExpiry),
1058 Just(CreateOrderField::IntegratorAccountIndex),
1059 Just(CreateOrderField::IntegratorTakerFee),
1060 Just(CreateOrderField::IntegratorMakerFee),
1061 Just(CreateOrderField::SkipNonce),
1062 ]
1063 }
1064
1065 fn mutate_create_order_field(
1069 base: CreateOrderTxInfo,
1070 chain_id: u32,
1071 field: CreateOrderField,
1072 ) -> (CreateOrderTxInfo, u32) {
1073 let mut alt = base;
1074 let mut chain = chain_id;
1075 match field {
1076 CreateOrderField::ChainId => chain = chain.wrapping_add(1),
1077 CreateOrderField::AccountIndex => {
1078 alt.context.account_index = alt.context.account_index.wrapping_add(1);
1079 }
1080 CreateOrderField::ApiKeyIndex => {
1081 alt.context.api_key_index = alt.context.api_key_index.wrapping_add(1);
1082 }
1083 CreateOrderField::Nonce => alt.context.nonce = alt.context.nonce.wrapping_add(1),
1084 CreateOrderField::ExpiredAt => {
1085 alt.context.expired_at = alt.context.expired_at.wrapping_add(1);
1086 }
1087 CreateOrderField::MarketIndex => {
1088 alt.order.market_index = alt.order.market_index.wrapping_add(1);
1089 }
1090 CreateOrderField::ClientOrderIndex => {
1091 alt.order.client_order_index = alt.order.client_order_index.wrapping_add(1);
1092 }
1093 CreateOrderField::BaseAmount => {
1094 alt.order.base_amount = alt.order.base_amount.wrapping_add(1);
1095 }
1096 CreateOrderField::Price => alt.order.price = alt.order.price.wrapping_add(1),
1097 CreateOrderField::IsAsk => alt.order.is_ask = !alt.order.is_ask,
1098 CreateOrderField::OrderType => {
1099 alt.order.order_type = alt.order.order_type.wrapping_add(1);
1100 }
1101 CreateOrderField::TimeInForce => {
1102 alt.order.time_in_force = alt.order.time_in_force.wrapping_add(1);
1103 }
1104 CreateOrderField::ReduceOnly => alt.order.reduce_only = !alt.order.reduce_only,
1105 CreateOrderField::TriggerPrice => {
1106 alt.order.trigger_price = alt.order.trigger_price.wrapping_add(1);
1107 }
1108 CreateOrderField::OrderExpiry => {
1109 alt.order.order_expiry = alt.order.order_expiry.wrapping_add(1);
1110 }
1111 CreateOrderField::IntegratorAccountIndex => {
1112 alt.attributes.integrator_account_index =
1113 alt.attributes.integrator_account_index.wrapping_add(1);
1114 }
1115 CreateOrderField::IntegratorTakerFee => {
1116 alt.attributes.integrator_taker_fee =
1117 alt.attributes.integrator_taker_fee.wrapping_add(1);
1118 }
1119 CreateOrderField::IntegratorMakerFee => {
1120 alt.attributes.integrator_maker_fee =
1121 alt.attributes.integrator_maker_fee.wrapping_add(1);
1122 }
1123 CreateOrderField::SkipNonce => {
1124 alt.attributes.skip_nonce = alt.attributes.skip_nonce.wrapping_add(1);
1125 }
1126 }
1127 (alt, chain)
1128 }
1129
1130 proptest! {
1131 #[rstest]
1141 fn prop_field_change_changes_preimage(
1142 base in arb_create_order(),
1143 chain_id in any::<u32>(),
1144 field in arb_create_order_field(),
1145 ) {
1146 let (alt, chain) = mutate_create_order_field(base, chain_id, field);
1147 let base_preimage = (base.hash_elements(chain_id), base.attributes());
1148 let alt_preimage = (alt.hash_elements(chain), alt.attributes());
1149 prop_assert_ne!(
1150 base_preimage,
1151 alt_preimage,
1152 "mutation {:?} did not change preimage",
1153 field,
1154 );
1155 }
1156
1157 #[rstest]
1162 fn prop_compute_tx_hash_matches_explicit_formula(
1163 tx in arb_create_order(),
1164 chain_id in any::<u32>(),
1165 ) {
1166 prop_assert_eq!(compute_tx_hash(&tx, chain_id), explicit_tx_hash(&tx, chain_id));
1167 }
1168
1169 #[rstest]
1172 fn prop_sign_then_verify_for_arbitrary_tx(
1173 tx in arb_create_order(),
1174 chain_id in any::<u32>(),
1175 sk in arb_scalar_nonzero(),
1176 k in arb_scalar_nonzero(),
1177 ) {
1178 let private_key = PrivateKey::from_scalar(sk);
1179 let pk = private_key.public_key();
1180 let signed = sign_tx(&tx, chain_id, &private_key, k);
1181
1182 prop_assert_eq!(signed.tx_hash, compute_tx_hash(&tx, chain_id));
1185
1186 let hashed = Fp5::try_from_le_bytes(signed.tx_hash)
1187 .expect("tx hash must encode a canonical Fp5");
1188 prop_assert!(pk.verify(hashed, &signed.sig));
1189 }
1190 }
1191
1192 #[rstest]
1193 fn cancel_order_json_emits_skip_nonce_only_attribute() {
1194 let tx = CancelOrderTxInfo {
1196 context: TxContext {
1197 account_index: 1,
1198 api_key_index: 0,
1199 nonce: 0,
1200 expired_at: 0,
1201 },
1202 market_index: 0,
1203 index: 1,
1204 skip_nonce: 1,
1205 };
1206 let sk = PrivateKey::from_le_bytes_reduce([0x42; SCALAR_BYTES]);
1207 let signed = sign_tx(&tx, 300, &sk, Scalar::ONE);
1208 let json = TxInfoJson::cancel_order(&tx, &signed);
1209 assert!(
1210 json.ends_with(",\"L2TxAttributes\":{\"4\":1}}"),
1211 "was {json}"
1212 );
1213 }
1214}