use alloy::{
signers::{SignerSync, local::PrivateKeySigner},
sol_types::SolValue,
};
use alloy_primitives::{Address, B256, U256, keccak256};
use thiserror::Error;
use crate::{
common::consts::MIN_SIGNATURE_TTL,
signing::{encoding::utc_now_ms, modules::ModuleData},
};
#[derive(Debug, Error)]
pub enum TypedDataError {
#[error(
"signature expiry {expiry} must be at least {min_ttl_secs}s in the future of now {now}"
)]
ExpiryTooSoon {
expiry: i64,
now: i64,
min_ttl_secs: i64,
},
#[error("system clock is before UNIX epoch")]
ClockBeforeEpoch,
#[error("signing failed: {message}")]
SigningFailed {
message: String,
},
#[error("module data encoding failed: {message}")]
ModuleEncoding {
message: String,
},
}
#[derive(Debug, Clone)]
pub struct ActionContext {
pub subaccount_id: u64,
pub nonce: u64,
pub module_address: Address,
pub signature_expiry_sec: i64,
pub owner: Address,
pub signer: Address,
}
#[must_use]
pub fn compute_action_hash(
ctx: &ActionContext,
module_data_hash: B256,
action_typehash: B256,
) -> B256 {
let tuple = (
action_typehash,
U256::from(ctx.subaccount_id),
U256::from(ctx.nonce),
ctx.module_address,
module_data_hash,
U256::from(ctx.signature_expiry_sec),
ctx.owner,
ctx.signer,
);
keccak256(tuple.abi_encode())
}
#[must_use]
pub fn compute_typed_data_hash(domain_separator: B256, action_hash: B256) -> B256 {
let mut buf = Vec::with_capacity(2 + 32 + 32);
buf.push(0x19);
buf.push(0x01);
buf.extend_from_slice(domain_separator.as_slice());
buf.extend_from_slice(action_hash.as_slice());
keccak256(&buf)
}
#[derive(Debug)]
pub struct SignedAction<'a, M: ModuleData> {
ctx: ActionContext,
module_data: &'a M,
domain_separator: B256,
action_typehash: B256,
signature: Option<[u8; 65]>,
}
impl<'a, M: ModuleData> SignedAction<'a, M> {
#[must_use]
pub fn new(
ctx: ActionContext,
module_data: &'a M,
domain_separator: B256,
action_typehash: B256,
) -> Self {
Self {
ctx,
module_data,
domain_separator,
action_typehash,
signature: None,
}
}
pub fn sign(&mut self, signer: &PrivateKeySigner) -> Result<[u8; 65], TypedDataError> {
self.validate_expiry()?;
let module_data_bytes =
self.module_data
.to_abi_encoded()
.map_err(|e| TypedDataError::ModuleEncoding {
message: e.to_string(),
})?;
let module_data_hash = keccak256(module_data_bytes);
let action_hash = compute_action_hash(&self.ctx, module_data_hash, self.action_typehash);
let typed_data_hash = compute_typed_data_hash(self.domain_separator, action_hash);
let signature =
signer
.sign_hash_sync(&typed_data_hash)
.map_err(|e| TypedDataError::SigningFailed {
message: e.to_string(),
})?;
let bytes = signature.as_bytes();
self.signature = Some(bytes);
Ok(bytes)
}
#[must_use]
pub fn signature_hex(&self) -> String {
let bytes = self.signature.expect("signature_hex called before sign");
format!("0x{}", alloy_primitives::hex::encode(bytes))
}
#[must_use]
pub const fn subaccount_id(&self) -> u64 {
self.ctx.subaccount_id
}
#[must_use]
pub const fn nonce(&self) -> u64 {
self.ctx.nonce
}
#[must_use]
pub const fn signer_address(&self) -> Address {
self.ctx.signer
}
#[must_use]
pub const fn signature_expiry_sec(&self) -> i64 {
self.ctx.signature_expiry_sec
}
fn validate_expiry(&self) -> Result<(), TypedDataError> {
let now_ms = utc_now_ms().map_err(|_| TypedDataError::ClockBeforeEpoch)?;
let now_secs = (now_ms / 1000) as i64;
let min_ttl_secs = MIN_SIGNATURE_TTL.as_secs() as i64;
if self.ctx.signature_expiry_sec < now_secs.saturating_add(min_ttl_secs) {
return Err(TypedDataError::ExpiryTooSoon {
expiry: self.ctx.signature_expiry_sec,
now: now_secs,
min_ttl_secs,
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use alloy_primitives::{Signature, hex};
use rstest::rstest;
use rust_decimal_macros::dec;
use super::*;
use crate::signing::modules::trade::TradeModuleData;
const SESSION_KEY_HEX: &str =
"0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
fn fixed_typehash() -> B256 {
"0x1111111111111111111111111111111111111111111111111111111111111111"
.parse()
.unwrap()
}
fn fixed_domain() -> B256 {
"0x2222222222222222222222222222222222222222222222222222222222222222"
.parse()
.unwrap()
}
fn module_addr() -> Address {
"0x000000000000000000000000000000000000bbbb"
.parse()
.unwrap()
}
fn owner() -> Address {
"0x000000000000000000000000000000000000aaaa"
.parse()
.unwrap()
}
fn fresh_expiry() -> i64 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
now + 3600
}
fn sample_trade() -> TradeModuleData {
TradeModuleData {
asset_address: "0x000000000000000000000000000000000000abcd"
.parse()
.unwrap(),
sub_id: U256::from(42),
limit_price: dec!(100),
amount: dec!(1),
max_fee: dec!(1000),
recipient_id: 30769,
is_bid: true,
}
}
fn sample_ctx(signer: Address, expiry: i64) -> ActionContext {
ActionContext {
subaccount_id: 30769,
nonce: 1_695_836_058_725_001,
module_address: module_addr(),
signature_expiry_sec: expiry,
owner: owner(),
signer,
}
}
#[rstest]
fn test_compute_action_hash_changes_with_subaccount() {
let module_hash = keccak256(sample_trade().to_abi_encoded().unwrap());
let mut ctx = sample_ctx(owner(), fresh_expiry());
let h1 = compute_action_hash(&ctx, module_hash, fixed_typehash());
ctx.subaccount_id += 1;
let h2 = compute_action_hash(&ctx, module_hash, fixed_typehash());
assert_ne!(h1, h2, "changing subaccount must change the hash");
}
#[rstest]
fn test_compute_action_hash_pins_byte_layout() {
let module_hash: B256 =
"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.parse()
.unwrap();
let ctx = ActionContext {
subaccount_id: 30769,
nonce: 1_695_836_058_725_001,
module_address: module_addr(),
signature_expiry_sec: 1_700_000_000,
owner: owner(),
signer: "0x000000000000000000000000000000000000cccc"
.parse()
.unwrap(),
};
let hash = compute_action_hash(&ctx, module_hash, fixed_typehash());
let expected = "0x509b526a0413577f827d7ebaf5b3fed1eb24bb480612b4e705e1001126f04a1b";
assert_eq!(format!("{hash:?}"), expected, "action-hash layout drift");
}
#[rstest]
fn test_compute_typed_data_hash_pins_byte_layout() {
let action_hash: B256 =
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
.parse()
.unwrap();
let hash = compute_typed_data_hash(fixed_domain(), action_hash);
let expected = "0x939b63f7cb4f2902be3004edd4f758ce4af26b96d12fd0992957a4cf5d287312";
assert_eq!(
format!("{hash:?}"),
expected,
"typed-data hash composition drift",
);
}
#[rstest]
fn test_compute_typed_data_hash_includes_19_01_prefix() {
let action_hash: B256 =
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
.parse()
.unwrap();
let with_prefix = compute_typed_data_hash(fixed_domain(), action_hash);
let mut bare = Vec::with_capacity(64);
bare.extend_from_slice(fixed_domain().as_slice());
bare.extend_from_slice(action_hash.as_slice());
let without_prefix = keccak256(&bare);
assert_ne!(
with_prefix, without_prefix,
"the 0x1901 prefix must change the digest",
);
}
#[rstest]
fn test_sign_rejects_expiry_that_is_too_soon() {
let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
let near_expiry = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
+ 60; let ctx = sample_ctx(signer.address(), near_expiry);
let trade = sample_trade();
let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
let err = action.sign(&signer).expect_err("must reject near expiry");
assert!(
matches!(err, TypedDataError::ExpiryTooSoon { .. }),
"expected ExpiryTooSoon, was {err:?}",
);
}
#[rstest]
fn test_sign_produces_recoverable_signature() {
let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
let ctx = sample_ctx(signer.address(), fresh_expiry());
let trade = sample_trade();
let module_data_hash = keccak256(trade.to_abi_encoded().unwrap());
let action_hash = compute_action_hash(&ctx, module_data_hash, fixed_typehash());
let typed_data_hash = compute_typed_data_hash(fixed_domain(), action_hash);
let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
let raw = action.sign(&signer).expect("sign must succeed");
assert_eq!(raw.len(), 65);
let signature = Signature::try_from(raw.as_slice()).expect("65-byte sig");
let recovered = signature
.recover_address_from_prehash(&typed_data_hash)
.expect("recover");
assert_eq!(recovered, signer.address());
}
#[rstest]
fn test_sign_propagates_module_encoding_error() {
let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
let ctx = sample_ctx(signer.address(), fresh_expiry());
let mut bad_trade = sample_trade();
bad_trade.max_fee = dec!(-1);
let mut action = SignedAction::new(ctx, &bad_trade, fixed_domain(), fixed_typehash());
let err = action
.sign(&signer)
.expect_err("invalid trade input must surface as a typed error, not a panic");
match err {
TypedDataError::ModuleEncoding { message } => {
assert!(message.contains("max_fee"), "unexpected message: {message}");
}
other => panic!("expected ModuleEncoding, was {other:?}"),
}
}
#[rstest]
fn test_signed_action_accessors_expose_request_envelope_fields() {
let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
let ctx = sample_ctx(signer.address(), fresh_expiry());
let trade = sample_trade();
let mut action = SignedAction::new(ctx, &trade, fixed_domain(), fixed_typehash());
action.sign(&signer).unwrap();
let sig = action.signature_hex();
assert!(sig.starts_with("0x"));
assert_eq!(sig.len(), 2 + 130, "0x + 65 bytes hex = 132 chars");
assert_eq!(action.nonce(), 1_695_836_058_725_001_u64);
assert_eq!(action.subaccount_id(), 30769);
assert_eq!(action.signer_address(), signer.address());
assert!(action.signature_expiry_sec() > 0);
let bytes = hex::decode(sig.trim_start_matches("0x")).unwrap();
assert_eq!(bytes.len(), 65);
}
}