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