1use alloy::{
29 signers::{SignerSync, local::PrivateKeySigner},
30 sol_types::SolValue,
31};
32use alloy_primitives::{Address, B256, U256, keccak256};
33use nautilus_core::string::secret::REDACTED;
34use thiserror::Error;
35use zeroize::{Zeroize, ZeroizeOnDrop};
36
37use crate::{
38 common::consts::MIN_SIGNATURE_TTL,
39 signing::{encoding::utc_now_ms, modules::ModuleData},
40};
41
42#[derive(Debug, Error)]
44pub enum TypedDataError {
45 #[error(
48 "signature expiry {expiry} must be at least {min_ttl_secs}s in the future of now {now}"
49 )]
50 ExpiryTooSoon {
51 expiry: i64,
53 now: i64,
55 min_ttl_secs: i64,
57 },
58 #[error("system clock is before UNIX epoch")]
60 ClockBeforeEpoch,
61 #[error("signing failed: {message}")]
63 SigningFailed {
64 message: String,
66 },
67 #[error("module data encoding failed: {message}")]
70 ModuleEncoding {
71 message: String,
73 },
74}
75
76#[derive(Debug, Clone)]
78pub struct ActionContext {
79 pub subaccount_id: u64,
81 pub nonce: u64,
83 pub module_address: Address,
85 pub signature_expiry_sec: i64,
87 pub owner: Address,
89 pub signer: Address,
91}
92
93#[must_use]
98pub fn compute_action_hash(
99 ctx: &ActionContext,
100 module_data_hash: B256,
101 action_typehash: B256,
102) -> B256 {
103 let tuple = (
104 action_typehash,
105 U256::from(ctx.subaccount_id),
106 U256::from(ctx.nonce),
107 ctx.module_address,
108 module_data_hash,
109 U256::from(ctx.signature_expiry_sec),
110 ctx.owner,
111 ctx.signer,
112 );
113 keccak256(tuple.abi_encode())
114}
115
116#[must_use]
120pub fn compute_typed_data_hash(domain_separator: B256, action_hash: B256) -> B256 {
121 let mut buf = Vec::with_capacity(2 + 32 + 32);
122 buf.push(0x19);
123 buf.push(0x01);
124 buf.extend_from_slice(domain_separator.as_slice());
125 buf.extend_from_slice(action_hash.as_slice());
126 keccak256(&buf)
127}
128
129pub struct SignedAction<'a, M: ModuleData> {
135 ctx: ActionContext,
136 module_data: &'a M,
137 domain_separator: B256,
138 action_typehash: B256,
139 signature: Option<[u8; 65]>,
140}
141
142impl<M> std::fmt::Debug for SignedAction<'_, M>
143where
144 M: ModuleData + std::fmt::Debug,
145{
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 f.debug_struct(stringify!(SignedAction))
148 .field("ctx", &self.ctx)
149 .field("module_data", &self.module_data)
150 .field("domain_separator", &self.domain_separator)
151 .field("action_typehash", &self.action_typehash)
152 .field("signature", &self.signature.map(|_| REDACTED))
153 .finish()
154 }
155}
156
157impl<M: ModuleData> Zeroize for SignedAction<'_, M> {
158 fn zeroize(&mut self) {
159 self.signature.zeroize();
160 }
161}
162
163impl<M: ModuleData> Drop for SignedAction<'_, M> {
164 fn drop(&mut self) {
165 self.zeroize();
166 }
167}
168
169impl<M: ModuleData> ZeroizeOnDrop for SignedAction<'_, M> {}
170
171impl<'a, M: ModuleData> SignedAction<'a, M> {
172 #[must_use]
174 pub fn new(
175 ctx: ActionContext,
176 module_data: &'a M,
177 domain_separator: B256,
178 action_typehash: B256,
179 ) -> Self {
180 Self {
181 ctx,
182 module_data,
183 domain_separator,
184 action_typehash,
185 signature: None,
186 }
187 }
188
189 pub fn sign(&mut self, signer: &PrivateKeySigner) -> Result<[u8; 65], TypedDataError> {
204 self.validate_expiry()?;
205
206 let module_data_bytes =
207 self.module_data
208 .to_abi_encoded()
209 .map_err(|e| TypedDataError::ModuleEncoding {
210 message: e.to_string(),
211 })?;
212 let module_data_hash = keccak256(module_data_bytes);
213 let action_hash = compute_action_hash(&self.ctx, module_data_hash, self.action_typehash);
214 let typed_data_hash = compute_typed_data_hash(self.domain_separator, action_hash);
215
216 let signature =
217 signer
218 .sign_hash_sync(&typed_data_hash)
219 .map_err(|e| TypedDataError::SigningFailed {
220 message: e.to_string(),
221 })?;
222 let bytes = signature.as_bytes();
223 self.signature = Some(bytes);
224 Ok(bytes)
225 }
226
227 #[must_use]
234 pub fn signature_hex(&self) -> String {
235 let bytes = self.signature.expect("signature_hex called before sign");
236 format!("0x{}", alloy_primitives::hex::encode(bytes))
237 }
238
239 #[must_use]
241 pub const fn subaccount_id(&self) -> u64 {
242 self.ctx.subaccount_id
243 }
244
245 #[must_use]
247 pub const fn nonce(&self) -> u64 {
248 self.ctx.nonce
249 }
250
251 #[must_use]
253 pub const fn signer_address(&self) -> Address {
254 self.ctx.signer
255 }
256
257 #[must_use]
259 pub const fn signature_expiry_sec(&self) -> i64 {
260 self.ctx.signature_expiry_sec
261 }
262
263 fn validate_expiry(&self) -> Result<(), TypedDataError> {
264 let now_ms = utc_now_ms().map_err(|_| TypedDataError::ClockBeforeEpoch)?;
265 let now_secs = (now_ms / 1000) as i64;
266 let min_ttl_secs = MIN_SIGNATURE_TTL.as_secs() as i64;
267 if self.ctx.signature_expiry_sec < now_secs.saturating_add(min_ttl_secs) {
268 return Err(TypedDataError::ExpiryTooSoon {
269 expiry: self.ctx.signature_expiry_sec,
270 now: now_secs,
271 min_ttl_secs,
272 });
273 }
274 Ok(())
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use std::time::{SystemTime, UNIX_EPOCH};
281
282 use alloy_primitives::{Signature, hex};
283 use rstest::rstest;
284 use rust_decimal::Decimal;
285 use rust_decimal_macros::dec;
286 use serde::Deserialize;
287
288 use super::*;
289 use crate::{
290 common::{
291 consts::{ACTION_TYPEHASH, domain_separator_for, trade_module_address_for},
292 enums::DeriveEnvironment,
293 },
294 signing::modules::trade::TradeModuleData,
295 };
296
297 const SESSION_KEY_HEX: &str =
298 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
299
300 fn fixed_typehash() -> B256 {
301 "0x1111111111111111111111111111111111111111111111111111111111111111"
304 .parse()
305 .unwrap()
306 }
307
308 fn fixed_domain() -> B256 {
309 "0x2222222222222222222222222222222222222222222222222222222222222222"
310 .parse()
311 .unwrap()
312 }
313
314 fn module_addr() -> Address {
315 "0x000000000000000000000000000000000000bbbb"
316 .parse()
317 .unwrap()
318 }
319
320 fn owner() -> Address {
321 "0x000000000000000000000000000000000000aaaa"
322 .parse()
323 .unwrap()
324 }
325
326 fn fresh_expiry() -> i64 {
327 let now = SystemTime::now()
328 .duration_since(UNIX_EPOCH)
329 .unwrap()
330 .as_secs() as i64;
331 now + 3600
332 }
333
334 fn sample_trade() -> TradeModuleData {
335 TradeModuleData {
336 asset_address: "0x000000000000000000000000000000000000abcd"
337 .parse()
338 .unwrap(),
339 sub_id: U256::from(42),
340 limit_price: dec!(100),
341 amount: dec!(1),
342 max_fee: dec!(1000),
343 recipient_id: 30769,
344 is_bid: true,
345 }
346 }
347
348 fn sample_ctx(signer: Address, expiry: i64) -> ActionContext {
349 ActionContext {
350 subaccount_id: 30769,
351 nonce: 1_695_836_058_725_001,
352 module_address: module_addr(),
353 signature_expiry_sec: expiry,
354 owner: owner(),
355 signer,
356 }
357 }
358
359 #[rstest]
360 fn test_compute_action_hash_changes_with_subaccount() {
361 let module_hash = keccak256(sample_trade().to_abi_encoded().unwrap());
362 let mut ctx = sample_ctx(owner(), fresh_expiry());
363 let h1 = compute_action_hash(&ctx, module_hash, fixed_typehash());
364 ctx.subaccount_id += 1;
365 let h2 = compute_action_hash(&ctx, module_hash, fixed_typehash());
366 assert_ne!(h1, h2, "changing subaccount must change the hash");
367 }
368
369 #[rstest]
370 fn test_compute_action_hash_pins_byte_layout() {
371 let module_hash: B256 =
377 "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
378 .parse()
379 .unwrap();
380 let ctx = ActionContext {
381 subaccount_id: 30769,
382 nonce: 1_695_836_058_725_001,
383 module_address: module_addr(),
384 signature_expiry_sec: 1_700_000_000,
385 owner: owner(),
386 signer: "0x000000000000000000000000000000000000cccc"
387 .parse()
388 .unwrap(),
389 };
390 let hash = compute_action_hash(&ctx, module_hash, fixed_typehash());
391 let expected = "0x509b526a0413577f827d7ebaf5b3fed1eb24bb480612b4e705e1001126f04a1b";
392 assert_eq!(format!("{hash:?}"), expected, "action-hash layout drift");
393 }
394
395 #[rstest]
396 fn test_compute_typed_data_hash_pins_byte_layout() {
397 let action_hash: B256 =
401 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
402 .parse()
403 .unwrap();
404 let hash = compute_typed_data_hash(fixed_domain(), action_hash);
405 let expected = "0x939b63f7cb4f2902be3004edd4f758ce4af26b96d12fd0992957a4cf5d287312";
406 assert_eq!(
407 format!("{hash:?}"),
408 expected,
409 "typed-data hash composition drift",
410 );
411 }
412
413 #[rstest]
414 fn test_compute_typed_data_hash_includes_19_01_prefix() {
415 let action_hash: B256 =
418 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
419 .parse()
420 .unwrap();
421 let with_prefix = compute_typed_data_hash(fixed_domain(), action_hash);
422 let mut bare = Vec::with_capacity(64);
423 bare.extend_from_slice(fixed_domain().as_slice());
424 bare.extend_from_slice(action_hash.as_slice());
425 let without_prefix = keccak256(&bare);
426 assert_ne!(
427 with_prefix, without_prefix,
428 "the 0x1901 prefix must change the digest",
429 );
430 }
431
432 #[rstest]
433 fn test_sign_rejects_expiry_that_is_too_soon() {
434 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
435 let near_expiry = SystemTime::now()
436 .duration_since(UNIX_EPOCH)
437 .unwrap()
438 .as_secs() as i64
439 + 60; let ctx = sample_ctx(signer.address(), near_expiry);
441 let trade = sample_trade();
442 let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
443 let err = action.sign(&signer).expect_err("must reject near expiry");
444 assert!(
445 matches!(err, TypedDataError::ExpiryTooSoon { .. }),
446 "expected ExpiryTooSoon, was {err:?}",
447 );
448 }
449
450 #[rstest]
451 fn test_sign_produces_recoverable_signature() {
452 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
453 let ctx = sample_ctx(signer.address(), fresh_expiry());
454 let trade = sample_trade();
455
456 let module_data_hash = keccak256(trade.to_abi_encoded().unwrap());
457 let action_hash = compute_action_hash(&ctx, module_data_hash, fixed_typehash());
458 let typed_data_hash = compute_typed_data_hash(fixed_domain(), action_hash);
459
460 let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
461 let raw = action.sign(&signer).expect("sign must succeed");
462 let debug = format!("{action:?}");
463 assert_eq!(raw.len(), 65);
464 assert!(debug.contains(REDACTED));
465
466 let signature = Signature::try_from(raw.as_slice()).expect("65-byte sig");
469 let recovered = signature
470 .recover_address_from_prehash(&typed_data_hash)
471 .expect("recover");
472 assert_eq!(recovered, signer.address());
473
474 action.zeroize();
475 assert!(action.signature.is_none());
476 }
477
478 #[rstest]
479 fn test_sign_propagates_module_encoding_error() {
480 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
481 let ctx = sample_ctx(signer.address(), fresh_expiry());
482 let mut bad_trade = sample_trade();
483 bad_trade.max_fee = dec!(-1);
484 let mut action = SignedAction::new(ctx, &bad_trade, fixed_domain(), fixed_typehash());
485 let err = action
486 .sign(&signer)
487 .expect_err("invalid trade input must surface as a typed error, not a panic");
488
489 match err {
490 TypedDataError::ModuleEncoding { message } => {
491 assert!(message.contains("max_fee"), "unexpected message: {message}");
492 }
493 other => panic!("expected ModuleEncoding, was {other:?}"),
494 }
495 }
496
497 #[rstest]
498 fn test_signed_action_accessors_expose_request_envelope_fields() {
499 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
500 let ctx = sample_ctx(signer.address(), fresh_expiry());
501 let trade = sample_trade();
502 let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
503 action.sign(&signer).unwrap();
504
505 let sig = action.signature_hex();
506 assert!(sig.starts_with("0x"));
507 assert_eq!(sig.len(), 2 + 130, "0x + 65 bytes hex = 132 chars");
508 assert_eq!(action.nonce(), 1_695_836_058_725_001_u64);
509 assert_eq!(action.subaccount_id(), 30769);
510 assert_eq!(action.signer_address(), signer.address());
511 assert!(action.signature_expiry_sec() > 0);
512 let bytes = hex::decode(sig.trim_start_matches("0x")).unwrap();
514 assert_eq!(bytes.len(), 65);
515 }
516
517 const ORACLE_JSON: &str =
518 include_str!("../../test_data/common/signing_trade_action_vectors.json");
519
520 #[derive(Debug, Deserialize)]
521 struct OracleFile {
522 metadata: OracleMetadata,
523 vectors: Vec<OracleVector>,
524 }
525
526 #[derive(Debug, Deserialize)]
527 struct OracleMetadata {
528 source: String,
529 upstream_version: String,
530 upstream_revision: String,
531 generated_by: String,
532 }
533
534 #[derive(Debug, Deserialize)]
535 struct OracleVector {
536 case: String,
537 environment: String,
538 domain_separator: String,
539 action_typehash: String,
540 module_address: String,
541 subaccount_id: u64,
542 nonce: u64,
543 signature_expiry_sec: i64,
544 owner: String,
545 session_key: String,
546 signer: String,
547 trade: OracleTrade,
548 module_data: String,
549 module_data_hash: String,
550 action_hash: String,
551 typed_data_hash: String,
552 signature: String,
553 }
554
555 #[derive(Debug, Deserialize)]
556 struct OracleTrade {
557 asset_address: String,
558 sub_id: String,
559 limit_price: String,
560 amount: String,
561 max_fee: String,
562 recipient_id: u64,
563 is_bid: bool,
564 }
565
566 fn parse_oracle() -> OracleFile {
567 serde_json::from_str(ORACLE_JSON).expect("parse signing oracle fixture")
568 }
569
570 #[rstest]
571 fn test_oracle_fixture_records_upstream_provenance() {
572 let oracle = parse_oracle();
576 let metadata = &oracle.metadata;
577 assert!(!metadata.source.is_empty(), "oracle source missing");
578 assert!(
579 !metadata.upstream_version.is_empty(),
580 "oracle upstream version missing",
581 );
582 assert!(
583 !metadata.upstream_revision.is_empty(),
584 "oracle upstream revision missing",
585 );
586 assert!(
587 !metadata.generated_by.is_empty(),
588 "oracle generator path missing",
589 );
590 assert!(!oracle.vectors.is_empty(), "oracle fixture has no vectors");
591 }
592
593 #[rstest]
594 fn test_oracle_vectors_use_production_protocol_constants() {
595 let oracle = parse_oracle();
600
601 for (i, v) in oracle.vectors.iter().enumerate() {
602 let environment = match v.environment.as_str() {
603 "mainnet" => DeriveEnvironment::Mainnet,
604 "testnet" => DeriveEnvironment::Testnet,
605 other => panic!("vector {i}: unknown environment `{other}`"),
606 };
607 assert_eq!(
608 v.domain_separator,
609 domain_separator_for(environment),
610 "vector {i} ({}): domain separator does not match consts",
611 v.case,
612 );
613 assert_eq!(
614 v.action_typehash, ACTION_TYPEHASH,
615 "vector {i} ({}): action typehash does not match consts",
616 v.case,
617 );
618 assert_eq!(
619 v.module_address.to_ascii_lowercase(),
620 trade_module_address_for(environment).to_ascii_lowercase(),
621 "vector {i} ({}): trade module address does not match consts",
622 v.case,
623 );
624 }
625 }
626
627 #[rstest]
628 fn test_signing_matches_upstream_sdk_oracle_vectors() {
629 let oracle = parse_oracle();
635
636 for (i, v) in oracle.vectors.iter().enumerate() {
637 let trade = TradeModuleData {
638 asset_address: v.trade.asset_address.parse().unwrap(),
639 sub_id: U256::from_str_radix(&v.trade.sub_id, 10).unwrap(),
640 limit_price: v.trade.limit_price.parse::<Decimal>().unwrap(),
641 amount: v.trade.amount.parse::<Decimal>().unwrap(),
642 max_fee: v.trade.max_fee.parse::<Decimal>().unwrap(),
643 recipient_id: v.trade.recipient_id,
644 is_bid: v.trade.is_bid,
645 };
646
647 let signer: PrivateKeySigner = v.session_key.parse().unwrap();
648 assert_eq!(
649 signer.address(),
650 v.signer.parse::<Address>().unwrap(),
651 "vector {i} ({}): session key does not derive the fixture signer",
652 v.case,
653 );
654
655 let encoded = trade.to_abi_encoded().unwrap();
656 assert_eq!(
657 format!("0x{}", hex::encode(&encoded)),
658 v.module_data,
659 "vector {i} ({}): encoded module data diverged from upstream",
660 v.case,
661 );
662
663 let module_data_hash = keccak256(&encoded);
664 assert_eq!(
665 format!("{module_data_hash:?}"),
666 v.module_data_hash,
667 "vector {i} ({}): module-data hash diverged from upstream",
668 v.case,
669 );
670
671 let ctx = ActionContext {
672 subaccount_id: v.subaccount_id,
673 nonce: v.nonce,
674 module_address: v.module_address.parse().unwrap(),
675 signature_expiry_sec: v.signature_expiry_sec,
676 owner: v.owner.parse().unwrap(),
677 signer: v.signer.parse().unwrap(),
678 };
679 let action_typehash: B256 = v.action_typehash.parse().unwrap();
680 let action_hash = compute_action_hash(&ctx, module_data_hash, action_typehash);
681 assert_eq!(
682 format!("{action_hash:?}"),
683 v.action_hash,
684 "vector {i} ({}): action hash diverged from upstream",
685 v.case,
686 );
687
688 let domain_separator: B256 = v.domain_separator.parse().unwrap();
689 let typed_data_hash = compute_typed_data_hash(domain_separator, action_hash);
690 assert_eq!(
691 format!("{typed_data_hash:?}"),
692 v.typed_data_hash,
693 "vector {i} ({}): typed-data hash diverged from upstream",
694 v.case,
695 );
696
697 let mut action = SignedAction::new(ctx, &trade, domain_separator, action_typehash);
698 let signature = action
699 .sign(&signer)
700 .expect("oracle expiry is far future, signing must succeed");
701 assert_eq!(
702 format!("0x{}", hex::encode(signature)),
703 v.signature,
704 "vector {i} ({}): signature diverged from upstream",
705 v.case,
706 );
707 }
708 }
709}