Skip to main content

polyoxide_relay/
account.rs

1use crate::config::BuilderConfig;
2use crate::error::RelayError;
3use alloy::primitives::Address;
4use alloy::signers::local::PrivateKeySigner;
5
6/// Account credentials for authenticated relay operations.
7///
8/// Combines a private key signer (for EIP-712 transaction signing) with optional
9/// builder API credentials (for HMAC-authenticated relay submission). The `Debug`
10/// implementation redacts the private key to prevent accidental leakage in logs.
11#[derive(Clone)]
12pub struct BuilderAccount {
13    pub(crate) signer: PrivateKeySigner,
14    pub(crate) config: Option<BuilderConfig>,
15}
16
17impl std::fmt::Debug for BuilderAccount {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        f.debug_struct("BuilderAccount")
20            .field("address", &self.signer.address())
21            .field("config", &self.config)
22            .finish()
23    }
24}
25
26impl BuilderAccount {
27    /// Create a new account from a hex-encoded private key and optional builder config.
28    ///
29    /// Accepts keys with or without a `0x` prefix.
30    pub fn new(
31        private_key: impl Into<String>,
32        config: Option<BuilderConfig>,
33    ) -> Result<Self, RelayError> {
34        let signer = private_key
35            .into()
36            .parse::<PrivateKeySigner>()
37            .map_err(|e| RelayError::Signer(format!("Failed to parse private key: {}", e)))?;
38
39        Ok(Self { signer, config })
40    }
41
42    /// Returns the Ethereum address derived from the private key.
43    pub fn address(&self) -> Address {
44        self.signer.address()
45    }
46
47    /// Returns a reference to the underlying private key signer.
48    pub fn signer(&self) -> &PrivateKeySigner {
49        &self.signer
50    }
51
52    /// Returns the builder API config, if one was provided.
53    pub fn config(&self) -> Option<&BuilderConfig> {
54        self.config.as_ref()
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    // A well-known test private key (DO NOT use for real funds)
63    // Address: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 (anvil/hardhat default #0)
64    const TEST_PRIVATE_KEY: &str =
65        "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
66
67    #[test]
68    fn test_new_valid_private_key() {
69        let account = BuilderAccount::new(TEST_PRIVATE_KEY, None);
70        assert!(account.is_ok());
71    }
72
73    #[test]
74    fn test_new_with_0x_prefix() {
75        let key = format!("0x{}", TEST_PRIVATE_KEY);
76        let account = BuilderAccount::new(key, None);
77        // alloy accepts 0x-prefixed keys
78        assert!(account.is_ok());
79    }
80
81    #[test]
82    fn test_new_invalid_private_key() {
83        let result = BuilderAccount::new("not_a_valid_key", None);
84        assert!(result.is_err());
85        let err = result.unwrap_err();
86        match err {
87            RelayError::Signer(msg) => {
88                assert!(
89                    msg.contains("Failed to parse private key"),
90                    "unexpected: {msg}"
91                );
92            }
93            other => panic!("Expected Signer error, got: {other:?}"),
94        }
95    }
96
97    #[test]
98    fn test_new_empty_key() {
99        let result = BuilderAccount::new("", None);
100        assert!(result.is_err());
101    }
102
103    #[test]
104    fn test_address_derivation_deterministic() {
105        let a1 = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
106        let a2 = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
107        assert_eq!(a1.address(), a2.address());
108    }
109
110    #[test]
111    fn test_address_matches_known_value() {
112        // The first anvil/hardhat default account
113        let account = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
114        let expected: Address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
115            .parse()
116            .unwrap();
117        assert_eq!(account.address(), expected);
118    }
119
120    #[test]
121    fn test_debug_redacts_private_key() {
122        let account = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
123        let debug_output = format!("{:?}", account);
124        assert!(
125            debug_output.contains("address"),
126            "Debug should show address, got: {debug_output}"
127        );
128        assert!(
129            !debug_output.contains(TEST_PRIVATE_KEY),
130            "Debug should not contain the private key, got: {debug_output}"
131        );
132    }
133
134    #[test]
135    fn test_config_none() {
136        let account = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
137        assert!(account.config().is_none());
138    }
139
140    #[test]
141    fn test_config_some() {
142        let config = BuilderConfig::new("key".into(), "secret".into(), None);
143        let account = BuilderAccount::new(TEST_PRIVATE_KEY, Some(config)).unwrap();
144        assert!(account.config().is_some());
145    }
146}