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