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> {
200 self.validate_expiry()?;
201
202 let module_data_bytes =
203 self.module_data
204 .to_abi_encoded()
205 .map_err(|e| TypedDataError::ModuleEncoding {
206 message: e.to_string(),
207 })?;
208
209 let module_data_hash = keccak256(module_data_bytes);
210 let action_hash = compute_action_hash(&self.ctx, module_data_hash, self.action_typehash);
211 let typed_data_hash = compute_typed_data_hash(self.domain_separator, action_hash);
212
213 let signature =
214 signer
215 .sign_hash_sync(&typed_data_hash)
216 .map_err(|e| TypedDataError::SigningFailed {
217 message: e.to_string(),
218 })?;
219
220 let bytes = signature.as_bytes();
221 self.signature = Some(bytes);
222 Ok(bytes)
223 }
224
225 #[must_use]
232 pub fn signature_hex(&self) -> String {
233 let bytes = self.signature.expect("signature_hex called before sign");
234 format!("0x{}", alloy_primitives::hex::encode(bytes))
235 }
236
237 #[must_use]
239 pub const fn subaccount_id(&self) -> u64 {
240 self.ctx.subaccount_id
241 }
242
243 #[must_use]
245 pub const fn nonce(&self) -> u64 {
246 self.ctx.nonce
247 }
248
249 #[must_use]
251 pub const fn signer_address(&self) -> Address {
252 self.ctx.signer
253 }
254
255 #[must_use]
257 pub const fn signature_expiry_sec(&self) -> i64 {
258 self.ctx.signature_expiry_sec
259 }
260
261 fn validate_expiry(&self) -> Result<(), TypedDataError> {
262 let now_ms = utc_now_ms().map_err(|_| TypedDataError::ClockBeforeEpoch)?;
263 let now_secs = (now_ms / 1000) as i64;
264 let min_ttl_secs = MIN_SIGNATURE_TTL.as_secs() as i64;
265 if self.ctx.signature_expiry_sec < now_secs.saturating_add(min_ttl_secs) {
266 return Err(TypedDataError::ExpiryTooSoon {
267 expiry: self.ctx.signature_expiry_sec,
268 now: now_secs,
269 min_ttl_secs,
270 });
271 }
272
273 Ok(())
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use std::time::{SystemTime, UNIX_EPOCH};
280
281 use alloy_primitives::{Signature, hex};
282 use rstest::rstest;
283 use rust_decimal::Decimal;
284 use rust_decimal_macros::dec;
285 use serde::Deserialize;
286
287 use super::*;
288 use crate::{
289 common::{
290 consts::{ACTION_TYPEHASH, domain_separator_for, trade_module_address_for},
291 enums::DeriveEnvironment,
292 },
293 signing::modules::trade::TradeModuleData,
294 };
295
296 const SESSION_KEY_HEX: &str =
297 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
298
299 fn fixed_typehash() -> B256 {
300 "0x1111111111111111111111111111111111111111111111111111111111111111"
303 .parse()
304 .unwrap()
305 }
306
307 fn fixed_domain() -> B256 {
308 "0x2222222222222222222222222222222222222222222222222222222222222222"
309 .parse()
310 .unwrap()
311 }
312
313 fn module_addr() -> Address {
314 "0x000000000000000000000000000000000000bbbb"
315 .parse()
316 .unwrap()
317 }
318
319 fn owner() -> Address {
320 "0x000000000000000000000000000000000000aaaa"
321 .parse()
322 .unwrap()
323 }
324
325 fn fresh_expiry() -> i64 {
326 let now = SystemTime::now()
327 .duration_since(UNIX_EPOCH)
328 .unwrap()
329 .as_secs() as i64;
330 now + 3600
331 }
332
333 fn sample_trade() -> TradeModuleData {
334 TradeModuleData {
335 asset_address: "0x000000000000000000000000000000000000abcd"
336 .parse()
337 .unwrap(),
338 sub_id: U256::from(42),
339 limit_price: dec!(100),
340 amount: dec!(1),
341 max_fee: dec!(1000),
342 recipient_id: 30769,
343 is_bid: true,
344 }
345 }
346
347 fn sample_ctx(signer: Address, expiry: i64) -> ActionContext {
348 ActionContext {
349 subaccount_id: 30769,
350 nonce: 1_695_836_058_725_001,
351 module_address: module_addr(),
352 signature_expiry_sec: expiry,
353 owner: owner(),
354 signer,
355 }
356 }
357
358 #[rstest]
359 fn test_compute_action_hash_changes_with_subaccount() {
360 let module_hash = keccak256(sample_trade().to_abi_encoded().unwrap());
361 let mut ctx = sample_ctx(owner(), fresh_expiry());
362 let h1 = compute_action_hash(&ctx, module_hash, fixed_typehash());
363 ctx.subaccount_id += 1;
364 let h2 = compute_action_hash(&ctx, module_hash, fixed_typehash());
365 assert_ne!(h1, h2, "changing subaccount must change the hash");
366 }
367
368 #[rstest]
369 fn test_compute_action_hash_pins_byte_layout() {
370 let module_hash: B256 =
376 "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
377 .parse()
378 .unwrap();
379
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
391 let hash = compute_action_hash(&ctx, module_hash, fixed_typehash());
392 let expected = "0x509b526a0413577f827d7ebaf5b3fed1eb24bb480612b4e705e1001126f04a1b";
393 assert_eq!(format!("{hash:?}"), expected, "action-hash layout drift");
394 }
395
396 #[rstest]
397 fn test_compute_typed_data_hash_pins_byte_layout() {
398 let action_hash: B256 =
402 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
403 .parse()
404 .unwrap();
405 let hash = compute_typed_data_hash(fixed_domain(), action_hash);
406 let expected = "0x939b63f7cb4f2902be3004edd4f758ce4af26b96d12fd0992957a4cf5d287312";
407 assert_eq!(
408 format!("{hash:?}"),
409 expected,
410 "typed-data hash composition drift",
411 );
412 }
413
414 #[rstest]
415 fn test_compute_typed_data_hash_includes_19_01_prefix() {
416 let action_hash: B256 =
419 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
420 .parse()
421 .unwrap();
422 let with_prefix = compute_typed_data_hash(fixed_domain(), action_hash);
423 let mut bare = Vec::with_capacity(64);
424 bare.extend_from_slice(fixed_domain().as_slice());
425 bare.extend_from_slice(action_hash.as_slice());
426 let without_prefix = keccak256(&bare);
427 assert_ne!(
428 with_prefix, without_prefix,
429 "the 0x1901 prefix must change the digest",
430 );
431 }
432
433 #[rstest]
434 fn test_sign_rejects_expiry_that_is_too_soon() {
435 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
436 let near_expiry = SystemTime::now()
437 .duration_since(UNIX_EPOCH)
438 .unwrap()
439 .as_secs() as i64
440 + MIN_SIGNATURE_TTL.as_secs() as i64
441 - 1;
442 let ctx = sample_ctx(signer.address(), near_expiry);
443 let trade = sample_trade();
444 let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
445 let err = action.sign(&signer).expect_err("must reject near expiry");
446 assert!(
447 matches!(err, TypedDataError::ExpiryTooSoon { .. }),
448 "expected ExpiryTooSoon, was {err:?}",
449 );
450 }
451
452 #[rstest]
453 fn test_sign_produces_recoverable_signature() {
454 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
455 let ctx = sample_ctx(signer.address(), fresh_expiry());
456 let trade = sample_trade();
457
458 let module_data_hash = keccak256(trade.to_abi_encoded().unwrap());
459 let action_hash = compute_action_hash(&ctx, module_data_hash, fixed_typehash());
460 let typed_data_hash = compute_typed_data_hash(fixed_domain(), action_hash);
461
462 let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
463 let raw = action.sign(&signer).expect("sign must succeed");
464 let debug = format!("{action:?}");
465 assert_eq!(raw.len(), 65);
466 assert!(debug.contains(REDACTED));
467
468 let signature = Signature::try_from(raw.as_slice()).expect("65-byte sig");
471 let recovered = signature
472 .recover_address_from_prehash(&typed_data_hash)
473 .expect("recover");
474 assert_eq!(recovered, signer.address());
475
476 action.zeroize();
477 assert!(action.signature.is_none());
478 }
479
480 #[rstest]
481 fn test_sign_propagates_module_encoding_error() {
482 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
483 let ctx = sample_ctx(signer.address(), fresh_expiry());
484 let mut bad_trade = sample_trade();
485 bad_trade.max_fee = dec!(-1);
486 let mut action = SignedAction::new(ctx, &bad_trade, fixed_domain(), fixed_typehash());
487 let err = action
488 .sign(&signer)
489 .expect_err("invalid trade input must surface as a typed error, not a panic");
490
491 match err {
492 TypedDataError::ModuleEncoding { message } => {
493 assert!(message.contains("max_fee"), "unexpected message: {message}");
494 }
495 other => panic!("expected ModuleEncoding, was {other:?}"),
496 }
497 }
498
499 #[rstest]
500 fn test_signed_action_accessors_expose_request_envelope_fields() {
501 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
502 let ctx = sample_ctx(signer.address(), fresh_expiry());
503 let trade = sample_trade();
504 let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
505 action.sign(&signer).unwrap();
506
507 let sig = action.signature_hex();
508 assert!(sig.starts_with("0x"));
509 assert_eq!(sig.len(), 2 + 130, "0x + 65 bytes hex = 132 chars");
510 assert_eq!(action.nonce(), 1_695_836_058_725_001_u64);
511 assert_eq!(action.subaccount_id(), 30769);
512 assert_eq!(action.signer_address(), signer.address());
513 assert!(action.signature_expiry_sec() > 0);
514 let bytes = hex::decode(sig.trim_start_matches("0x")).unwrap();
516 assert_eq!(bytes.len(), 65);
517 }
518
519 const ORACLE_JSON: &str =
520 include_str!("../../test_data/common/signing_trade_action_vectors.json");
521
522 #[derive(Debug, Deserialize)]
523 struct OracleFile {
524 metadata: OracleMetadata,
525 vectors: Vec<OracleVector>,
526 }
527
528 #[derive(Debug, Deserialize)]
529 struct OracleMetadata {
530 source: String,
531 upstream_version: String,
532 upstream_revision: String,
533 generated_by: String,
534 }
535
536 #[derive(Debug, Deserialize)]
537 struct OracleVector {
538 case: String,
539 environment: String,
540 domain_separator: String,
541 action_typehash: String,
542 module_address: String,
543 subaccount_id: u64,
544 #[serde(deserialize_with = "crate::signing::nonce::deserialize_nonce")]
545 nonce: u64,
546 signature_expiry_sec: i64,
547 owner: String,
548 session_key: String,
549 signer: String,
550 trade: OracleTrade,
551 module_data: String,
552 module_data_hash: String,
553 action_hash: String,
554 typed_data_hash: String,
555 signature: String,
556 }
557
558 #[derive(Debug, Deserialize)]
559 struct OracleTrade {
560 asset_address: String,
561 sub_id: String,
562 limit_price: String,
563 amount: String,
564 max_fee: String,
565 recipient_id: u64,
566 is_bid: bool,
567 }
568
569 fn parse_oracle() -> OracleFile {
570 serde_json::from_str(ORACLE_JSON).expect("parse signing oracle fixture")
571 }
572
573 #[rstest]
574 fn test_oracle_fixture_records_upstream_provenance() {
575 let oracle = parse_oracle();
576 let metadata = &oracle.metadata;
577 assert_eq!(metadata.source, "https://github.com/derivexyz/derive-py");
578 assert_eq!(metadata.upstream_version, "0.1.4");
579 assert_eq!(
580 metadata.upstream_revision,
581 "fad785e6c328746b5f8a8219e14009670bc97a35"
582 );
583 assert_eq!(
584 metadata.generated_by,
585 "scripts/oracle-py/derive/generate_oracle.py"
586 );
587 assert!(!oracle.vectors.is_empty(), "oracle fixture has no vectors");
588 }
589
590 #[rstest]
591 fn test_oracle_vectors_use_production_protocol_constants() {
592 let oracle = parse_oracle();
597
598 for (i, v) in oracle.vectors.iter().enumerate() {
599 let environment = match v.environment.as_str() {
600 "mainnet" => DeriveEnvironment::Mainnet,
601 "testnet" => DeriveEnvironment::Testnet,
602 other => panic!("vector {i}: unknown environment `{other}`"),
603 };
604
605 assert_eq!(
606 v.domain_separator,
607 domain_separator_for(environment),
608 "vector {i} ({}): domain separator does not match consts",
609 v.case,
610 );
611 assert_eq!(
612 v.action_typehash, ACTION_TYPEHASH,
613 "vector {i} ({}): action typehash does not match consts",
614 v.case,
615 );
616 assert_eq!(
617 v.module_address.to_ascii_lowercase(),
618 trade_module_address_for(environment).to_ascii_lowercase(),
619 "vector {i} ({}): trade module address does not match consts",
620 v.case,
621 );
622 }
623 }
624
625 #[rstest]
626 fn test_signing_matches_v3_sdk_oracle_vectors() {
627 let oracle = parse_oracle();
633
634 for (i, v) in oracle.vectors.iter().enumerate() {
635 let trade = TradeModuleData {
636 asset_address: v.trade.asset_address.parse().unwrap(),
637 sub_id: U256::from_str_radix(&v.trade.sub_id, 10).unwrap(),
638 limit_price: v.trade.limit_price.parse::<Decimal>().unwrap(),
639 amount: v.trade.amount.parse::<Decimal>().unwrap(),
640 max_fee: v.trade.max_fee.parse::<Decimal>().unwrap(),
641 recipient_id: v.trade.recipient_id,
642 is_bid: v.trade.is_bid,
643 };
644
645 let signer: PrivateKeySigner = v.session_key.parse().unwrap();
646 assert_eq!(
647 signer.address(),
648 v.signer.parse::<Address>().unwrap(),
649 "vector {i} ({}): session key does not derive the fixture signer",
650 v.case,
651 );
652
653 let encoded = trade.to_abi_encoded().unwrap();
654 assert_eq!(
655 format!("0x{}", hex::encode(&encoded)),
656 v.module_data,
657 "vector {i} ({}): encoded module data diverged from upstream",
658 v.case,
659 );
660
661 let module_data_hash = keccak256(&encoded);
662 assert_eq!(
663 format!("{module_data_hash:?}"),
664 v.module_data_hash,
665 "vector {i} ({}): module-data hash diverged from upstream",
666 v.case,
667 );
668
669 let ctx = ActionContext {
670 subaccount_id: v.subaccount_id,
671 nonce: v.nonce,
672 module_address: v.module_address.parse().unwrap(),
673 signature_expiry_sec: v.signature_expiry_sec,
674 owner: v.owner.parse().unwrap(),
675 signer: v.signer.parse().unwrap(),
676 };
677
678 let action_typehash: B256 = v.action_typehash.parse().unwrap();
679 let action_hash = compute_action_hash(&ctx, module_data_hash, action_typehash);
680 assert_eq!(
681 format!("{action_hash:?}"),
682 v.action_hash,
683 "vector {i} ({}): action hash diverged from upstream",
684 v.case,
685 );
686
687 let domain_separator: B256 = v.domain_separator.parse().unwrap();
688 let typed_data_hash = compute_typed_data_hash(domain_separator, action_hash);
689 assert_eq!(
690 format!("{typed_data_hash:?}"),
691 v.typed_data_hash,
692 "vector {i} ({}): typed-data hash diverged from upstream",
693 v.case,
694 );
695
696 let mut action = SignedAction::new(ctx, &trade, domain_separator, action_typehash);
697 let signature = action
698 .sign(&signer)
699 .expect("oracle expiry is far future, signing must succeed");
700 assert_eq!(
701 format!("0x{}", hex::encode(signature)),
702 v.signature,
703 "vector {i} ({}): signature diverged from upstream",
704 v.case,
705 );
706 }
707 }
708}