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_macros::dec;
255
256 use super::*;
257 use crate::signing::modules::trade::TradeModuleData;
258
259 const SESSION_KEY_HEX: &str =
260 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
261
262 fn fixed_typehash() -> B256 {
263 "0x1111111111111111111111111111111111111111111111111111111111111111"
266 .parse()
267 .unwrap()
268 }
269
270 fn fixed_domain() -> B256 {
271 "0x2222222222222222222222222222222222222222222222222222222222222222"
272 .parse()
273 .unwrap()
274 }
275
276 fn module_addr() -> Address {
277 "0x000000000000000000000000000000000000bbbb"
278 .parse()
279 .unwrap()
280 }
281
282 fn owner() -> Address {
283 "0x000000000000000000000000000000000000aaaa"
284 .parse()
285 .unwrap()
286 }
287
288 fn fresh_expiry() -> i64 {
289 let now = SystemTime::now()
290 .duration_since(UNIX_EPOCH)
291 .unwrap()
292 .as_secs() as i64;
293 now + 3600
294 }
295
296 fn sample_trade() -> TradeModuleData {
297 TradeModuleData {
298 asset_address: "0x000000000000000000000000000000000000abcd"
299 .parse()
300 .unwrap(),
301 sub_id: U256::from(42),
302 limit_price: dec!(100),
303 amount: dec!(1),
304 max_fee: dec!(1000),
305 recipient_id: 30769,
306 is_bid: true,
307 }
308 }
309
310 fn sample_ctx(signer: Address, expiry: i64) -> ActionContext {
311 ActionContext {
312 subaccount_id: 30769,
313 nonce: 1_695_836_058_725_001,
314 module_address: module_addr(),
315 signature_expiry_sec: expiry,
316 owner: owner(),
317 signer,
318 }
319 }
320
321 #[rstest]
322 fn test_compute_action_hash_changes_with_subaccount() {
323 let module_hash = keccak256(sample_trade().to_abi_encoded().unwrap());
324 let mut ctx = sample_ctx(owner(), fresh_expiry());
325 let h1 = compute_action_hash(&ctx, module_hash, fixed_typehash());
326 ctx.subaccount_id += 1;
327 let h2 = compute_action_hash(&ctx, module_hash, fixed_typehash());
328 assert_ne!(h1, h2, "changing subaccount must change the hash");
329 }
330
331 #[rstest]
332 fn test_compute_action_hash_pins_byte_layout() {
333 let module_hash: B256 =
339 "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
340 .parse()
341 .unwrap();
342 let ctx = ActionContext {
343 subaccount_id: 30769,
344 nonce: 1_695_836_058_725_001,
345 module_address: module_addr(),
346 signature_expiry_sec: 1_700_000_000,
347 owner: owner(),
348 signer: "0x000000000000000000000000000000000000cccc"
349 .parse()
350 .unwrap(),
351 };
352 let hash = compute_action_hash(&ctx, module_hash, fixed_typehash());
353 let expected = "0x509b526a0413577f827d7ebaf5b3fed1eb24bb480612b4e705e1001126f04a1b";
354 assert_eq!(format!("{hash:?}"), expected, "action-hash layout drift");
355 }
356
357 #[rstest]
358 fn test_compute_typed_data_hash_pins_byte_layout() {
359 let action_hash: B256 =
363 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
364 .parse()
365 .unwrap();
366 let hash = compute_typed_data_hash(fixed_domain(), action_hash);
367 let expected = "0x939b63f7cb4f2902be3004edd4f758ce4af26b96d12fd0992957a4cf5d287312";
368 assert_eq!(
369 format!("{hash:?}"),
370 expected,
371 "typed-data hash composition drift",
372 );
373 }
374
375 #[rstest]
376 fn test_compute_typed_data_hash_includes_19_01_prefix() {
377 let action_hash: B256 =
380 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
381 .parse()
382 .unwrap();
383 let with_prefix = compute_typed_data_hash(fixed_domain(), action_hash);
384 let mut bare = Vec::with_capacity(64);
385 bare.extend_from_slice(fixed_domain().as_slice());
386 bare.extend_from_slice(action_hash.as_slice());
387 let without_prefix = keccak256(&bare);
388 assert_ne!(
389 with_prefix, without_prefix,
390 "the 0x1901 prefix must change the digest",
391 );
392 }
393
394 #[rstest]
395 fn test_sign_rejects_expiry_that_is_too_soon() {
396 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
397 let near_expiry = SystemTime::now()
398 .duration_since(UNIX_EPOCH)
399 .unwrap()
400 .as_secs() as i64
401 + 60; let ctx = sample_ctx(signer.address(), near_expiry);
403 let trade = sample_trade();
404 let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
405 let err = action.sign(&signer).expect_err("must reject near expiry");
406 assert!(
407 matches!(err, TypedDataError::ExpiryTooSoon { .. }),
408 "expected ExpiryTooSoon, was {err:?}",
409 );
410 }
411
412 #[rstest]
413 fn test_sign_produces_recoverable_signature() {
414 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
415 let ctx = sample_ctx(signer.address(), fresh_expiry());
416 let trade = sample_trade();
417
418 let module_data_hash = keccak256(trade.to_abi_encoded().unwrap());
419 let action_hash = compute_action_hash(&ctx, module_data_hash, fixed_typehash());
420 let typed_data_hash = compute_typed_data_hash(fixed_domain(), action_hash);
421
422 let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
423 let raw = action.sign(&signer).expect("sign must succeed");
424 assert_eq!(raw.len(), 65);
425
426 let signature = Signature::try_from(raw.as_slice()).expect("65-byte sig");
429 let recovered = signature
430 .recover_address_from_prehash(&typed_data_hash)
431 .expect("recover");
432 assert_eq!(recovered, signer.address());
433 }
434
435 #[rstest]
436 fn test_sign_propagates_module_encoding_error() {
437 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
438 let ctx = sample_ctx(signer.address(), fresh_expiry());
439 let mut bad_trade = sample_trade();
440 bad_trade.max_fee = dec!(-1);
441 let mut action = SignedAction::new(ctx, &bad_trade, fixed_domain(), fixed_typehash());
442 let err = action
443 .sign(&signer)
444 .expect_err("invalid trade input must surface as a typed error, not a panic");
445
446 match err {
447 TypedDataError::ModuleEncoding { message } => {
448 assert!(message.contains("max_fee"), "unexpected message: {message}");
449 }
450 other => panic!("expected ModuleEncoding, was {other:?}"),
451 }
452 }
453
454 #[rstest]
455 fn test_signed_action_accessors_expose_request_envelope_fields() {
456 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
457 let ctx = sample_ctx(signer.address(), fresh_expiry());
458 let trade = sample_trade();
459 let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
460 action.sign(&signer).unwrap();
461
462 let sig = action.signature_hex();
463 assert!(sig.starts_with("0x"));
464 assert_eq!(sig.len(), 2 + 130, "0x + 65 bytes hex = 132 chars");
465 assert_eq!(action.nonce(), 1_695_836_058_725_001_u64);
466 assert_eq!(action.subaccount_id(), 30769);
467 assert_eq!(action.signer_address(), signer.address());
468 assert!(action.signature_expiry_sec() > 0);
469 let bytes = hex::decode(sig.trim_start_matches("0x")).unwrap();
471 assert_eq!(bytes.len(), 65);
472 }
473}