Skip to main content

polyoxide_relay/
account.rs

1use crate::config::{AuthConfig, BuilderConfig, RelayerApiKeyConfig};
2use crate::error::RelayError;
3use alloy::primitives::Address;
4use alloy::signers::local::PrivateKeySigner;
5
6/// Keychain service name for Relay credentials.
7#[cfg(feature = "keychain")]
8pub const KEYCHAIN_SERVICE: &str = "polyoxide-relay";
9
10/// Account credentials for authenticated relay operations.
11///
12/// Combines a private key signer (for EIP-712 transaction signing) with an optional
13/// [`AuthConfig`] for relay submission. Two authentication schemes are supported:
14/// [`AuthConfig::Builder`] (HMAC-signed builder API credentials) and
15/// [`AuthConfig::RelayerApiKey`] (static relayer API key headers). The `Debug`
16/// implementation redacts the private key to prevent accidental leakage in logs.
17#[derive(Clone)]
18pub struct BuilderAccount {
19    pub(crate) signer: PrivateKeySigner,
20    pub(crate) config: Option<AuthConfig>,
21}
22
23fn parse_signer(private_key: impl Into<String>) -> Result<PrivateKeySigner, RelayError> {
24    private_key
25        .into()
26        .parse::<PrivateKeySigner>()
27        .map_err(|e| RelayError::Signer(format!("Failed to parse private key: {}", e)))
28}
29
30impl std::fmt::Debug for BuilderAccount {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.debug_struct("BuilderAccount")
33            .field("address", &self.signer.address())
34            .field("config", &self.config)
35            .finish()
36    }
37}
38
39impl BuilderAccount {
40    /// Create a new account from a hex-encoded private key and optional builder config.
41    ///
42    /// Wraps the `BuilderConfig` in [`AuthConfig::Builder`] internally.
43    /// Accepts keys with or without a `0x` prefix.
44    pub fn new(
45        private_key: impl Into<String>,
46        config: Option<BuilderConfig>,
47    ) -> Result<Self, RelayError> {
48        let signer = parse_signer(private_key)?;
49        Ok(Self {
50            signer,
51            config: config.map(AuthConfig::Builder),
52        })
53    }
54
55    /// Create a new account from a hex-encoded private key and relayer API key credentials.
56    pub fn with_relayer_api_key(
57        private_key: impl Into<String>,
58        key: String,
59        address: String,
60    ) -> Result<Self, RelayError> {
61        let signer = parse_signer(private_key)?;
62        let relayer = RelayerApiKeyConfig::new(key, address)?;
63        Ok(Self {
64            signer,
65            config: Some(AuthConfig::RelayerApiKey(relayer)),
66        })
67    }
68
69    /// Create a new account from a hex-encoded private key and a pre-built [`AuthConfig`].
70    pub fn with_auth_config(
71        private_key: impl Into<String>,
72        config: Option<AuthConfig>,
73    ) -> Result<Self, RelayError> {
74        let signer = parse_signer(private_key)?;
75        Ok(Self { signer, config })
76    }
77
78    /// Returns the Ethereum address derived from the private key.
79    pub fn address(&self) -> Address {
80        self.signer.address()
81    }
82
83    /// Returns a reference to the underlying private key signer.
84    pub fn signer(&self) -> &PrivateKeySigner {
85        &self.signer
86    }
87
88    /// Returns the auth config, if one was provided.
89    pub fn auth_config(&self) -> Option<&AuthConfig> {
90        self.config.as_ref()
91    }
92
93    /// Load account from the OS keychain with builder API credentials.
94    ///
95    /// Reads from the `polyoxide-relay` keychain service:
96    /// - `private_key`: Hex-encoded private key (required)
97    /// - `api_key`, `api_secret`: Builder API credentials (optional — if `api_key` is
98    ///   not found, the account is created without auth config)
99    /// - `passphrase`: Builder API passphrase (optional)
100    #[cfg(feature = "keychain")]
101    pub fn from_keychain() -> Result<Self, RelayError> {
102        use polyoxide_core::keychain;
103
104        let private_key = keychain::get(KEYCHAIN_SERVICE, "private_key")
105            .map_err(|e| RelayError::Api(format!("Keychain error for private_key: {e}")))?;
106
107        let config = match keychain::get(KEYCHAIN_SERVICE, "api_key") {
108            Ok(key) => {
109                let secret = keychain::get(KEYCHAIN_SERVICE, "api_secret")
110                    .map_err(|e| RelayError::Api(format!("Keychain error for api_secret: {e}")))?;
111                let passphrase = keychain::get(KEYCHAIN_SERVICE, "passphrase").ok();
112                Some(BuilderConfig::new(key, secret, passphrase))
113            }
114            Err(polyoxide_core::KeychainError::NotFound { .. }) => None,
115            Err(e) => return Err(RelayError::Api(format!("Keychain error: {e}"))),
116        };
117
118        Self::new(private_key, config)
119    }
120
121    /// Load account from the OS keychain with relayer API key credentials.
122    ///
123    /// Reads from the `polyoxide-relay` keychain service:
124    /// - `private_key`: Hex-encoded private key
125    /// - `relayer_api_key`: Static relayer API key
126    /// - `relayer_api_key_address`: On-chain address for the relayer API key
127    #[cfg(feature = "keychain")]
128    pub fn from_keychain_relayer_api_key() -> Result<Self, RelayError> {
129        use polyoxide_core::keychain;
130
131        let private_key = keychain::get(KEYCHAIN_SERVICE, "private_key")
132            .map_err(|e| RelayError::Api(format!("Keychain error for private_key: {e}")))?;
133        let key = keychain::get(KEYCHAIN_SERVICE, "relayer_api_key")
134            .map_err(|e| RelayError::Api(format!("Keychain error for relayer_api_key: {e}")))?;
135        let address = keychain::get(KEYCHAIN_SERVICE, "relayer_api_key_address").map_err(|e| {
136            RelayError::Api(format!("Keychain error for relayer_api_key_address: {e}"))
137        })?;
138
139        Self::with_relayer_api_key(private_key, key, address)
140    }
141
142    /// Delete all credentials from the OS keychain for this service.
143    #[cfg(feature = "keychain")]
144    pub fn delete_from_keychain() -> Result<(), RelayError> {
145        use polyoxide_core::keychain;
146
147        for key in [
148            "private_key",
149            "api_key",
150            "api_secret",
151            "passphrase",
152            "relayer_api_key",
153            "relayer_api_key_address",
154        ] {
155            keychain::delete(KEYCHAIN_SERVICE, key)
156                .map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
157        }
158        Ok(())
159    }
160}
161
162/// Save a private key to the OS keychain under the `polyoxide-relay` service.
163#[cfg(feature = "keychain")]
164pub fn save_private_key_to_keychain(private_key: &str) -> Result<(), RelayError> {
165    polyoxide_core::keychain::set(KEYCHAIN_SERVICE, "private_key", private_key)
166        .map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
167    Ok(())
168}
169
170/// Save builder API credentials to the OS keychain under the `polyoxide-relay` service.
171///
172/// When `config.passphrase` is `None`, any previously stored passphrase is deleted
173/// to prevent stale values from persisting.
174#[cfg(feature = "keychain")]
175pub fn save_builder_config_to_keychain(config: &BuilderConfig) -> Result<(), RelayError> {
176    use polyoxide_core::keychain;
177
178    keychain::set(KEYCHAIN_SERVICE, "api_key", &config.key)
179        .map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
180    keychain::set(KEYCHAIN_SERVICE, "api_secret", &config.secret)
181        .map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
182    match &config.passphrase {
183        Some(passphrase) => {
184            keychain::set(KEYCHAIN_SERVICE, "passphrase", passphrase)
185                .map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
186        }
187        None => {
188            keychain::delete(KEYCHAIN_SERVICE, "passphrase")
189                .map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
190        }
191    }
192    Ok(())
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::config::AuthConfig;
199
200    // A well-known test private key (DO NOT use for real funds)
201    // Address: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 (anvil/hardhat default #0)
202    const TEST_PRIVATE_KEY: &str =
203        "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
204
205    #[test]
206    fn test_new_valid_private_key() {
207        let account = BuilderAccount::new(TEST_PRIVATE_KEY, None);
208        assert!(account.is_ok());
209    }
210
211    #[test]
212    fn test_new_with_0x_prefix() {
213        let key = format!("0x{}", TEST_PRIVATE_KEY);
214        let account = BuilderAccount::new(key, None);
215        // alloy accepts 0x-prefixed keys
216        assert!(account.is_ok());
217    }
218
219    #[test]
220    fn test_new_invalid_private_key() {
221        let result = BuilderAccount::new("not_a_valid_key", None);
222        assert!(result.is_err());
223        let err = result.unwrap_err();
224        match err {
225            RelayError::Signer(msg) => {
226                assert!(
227                    msg.contains("Failed to parse private key"),
228                    "unexpected: {msg}"
229                );
230            }
231            other => panic!("Expected Signer error, got: {other:?}"),
232        }
233    }
234
235    #[test]
236    fn test_new_empty_key() {
237        let result = BuilderAccount::new("", None);
238        assert!(result.is_err());
239    }
240
241    #[test]
242    fn test_address_derivation_deterministic() {
243        let a1 = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
244        let a2 = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
245        assert_eq!(a1.address(), a2.address());
246    }
247
248    #[test]
249    fn test_address_matches_known_value() {
250        // The first anvil/hardhat default account
251        let account = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
252        let expected: Address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
253            .parse()
254            .unwrap();
255        assert_eq!(account.address(), expected);
256    }
257
258    #[test]
259    fn test_debug_redacts_private_key() {
260        let account = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
261        let debug_output = format!("{:?}", account);
262        assert!(
263            debug_output.contains("address"),
264            "Debug should show address, got: {debug_output}"
265        );
266        assert!(
267            !debug_output.contains(TEST_PRIVATE_KEY),
268            "Debug should not contain the private key, got: {debug_output}"
269        );
270    }
271
272    #[test]
273    fn test_config_none() {
274        let account = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
275        assert!(account.auth_config().is_none());
276    }
277
278    #[test]
279    fn test_config_some() {
280        let config = BuilderConfig::new("key".into(), "secret".into(), None);
281        let account = BuilderAccount::new(TEST_PRIVATE_KEY, Some(config)).unwrap();
282        assert!(account.auth_config().is_some());
283    }
284
285    #[test]
286    fn test_with_relayer_api_key() {
287        let account = BuilderAccount::with_relayer_api_key(
288            TEST_PRIVATE_KEY,
289            "my-key".to_string(),
290            "0xaddr".to_string(),
291        )
292        .unwrap();
293        assert!(account.auth_config().is_some());
294        assert!(matches!(
295            account.auth_config(),
296            Some(AuthConfig::RelayerApiKey(_))
297        ));
298    }
299
300    #[test]
301    fn test_new_wraps_builder_config_in_auth_config() {
302        let config = BuilderConfig::new("key".into(), "secret".into(), None);
303        let account = BuilderAccount::new(TEST_PRIVATE_KEY, Some(config)).unwrap();
304        assert!(matches!(
305            account.auth_config(),
306            Some(AuthConfig::Builder(_))
307        ));
308    }
309
310    #[test]
311    fn test_with_auth_config_none() {
312        let account = BuilderAccount::with_auth_config(TEST_PRIVATE_KEY, None).unwrap();
313        assert!(account.auth_config().is_none());
314    }
315
316    #[test]
317    fn test_with_auth_config_relayer_api_key_variant() {
318        let relayer =
319            crate::config::RelayerApiKeyConfig::new("rk".into(), "0xaddr".into()).unwrap();
320        let auth = AuthConfig::RelayerApiKey(relayer);
321        let account = BuilderAccount::with_auth_config(TEST_PRIVATE_KEY, Some(auth)).unwrap();
322        assert!(matches!(
323            account.auth_config(),
324            Some(AuthConfig::RelayerApiKey(_))
325        ));
326    }
327
328    #[cfg(feature = "keychain")]
329    mod keychain_tests {
330        use super::*;
331
332        const TEST_PRIVATE_KEY: &str =
333            "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
334
335        #[test]
336        #[ignore] // Requires OS keychain daemon
337        fn builder_account_keychain_roundtrip() {
338            save_private_key_to_keychain(TEST_PRIVATE_KEY).unwrap();
339            let config = BuilderConfig::new("rk".into(), "rs".into(), Some("rp".into()));
340            save_builder_config_to_keychain(&config).unwrap();
341
342            let account = BuilderAccount::from_keychain().unwrap();
343            assert_eq!(
344                account.address(),
345                BuilderAccount::new(TEST_PRIVATE_KEY, None)
346                    .unwrap()
347                    .address()
348            );
349            assert!(account.auth_config().is_some());
350
351            // Cleanup
352            BuilderAccount::delete_from_keychain().unwrap();
353        }
354
355        #[test]
356        #[ignore] // Requires OS keychain daemon
357        fn builder_account_keychain_no_config() {
358            // Clear any leftover builder config entries so from_keychain()
359            // exercises the "no api_key found" path.
360            use polyoxide_core::keychain;
361            let _ = keychain::delete(KEYCHAIN_SERVICE, "api_key");
362            let _ = keychain::delete(KEYCHAIN_SERVICE, "api_secret");
363            let _ = keychain::delete(KEYCHAIN_SERVICE, "passphrase");
364
365            save_private_key_to_keychain(TEST_PRIVATE_KEY).unwrap();
366
367            let account = BuilderAccount::from_keychain().unwrap();
368            assert!(
369                account.auth_config().is_none(),
370                "Expected no auth config when api_key is absent"
371            );
372
373            // Cleanup
374            let _ = keychain::delete(KEYCHAIN_SERVICE, "private_key");
375        }
376
377        #[test]
378        #[ignore] // Requires OS keychain daemon
379        fn save_builder_config_none_passphrase_clears_stale() {
380            use polyoxide_core::keychain;
381
382            // Store config WITH passphrase
383            save_private_key_to_keychain(TEST_PRIVATE_KEY).unwrap();
384            let config_with = BuilderConfig::new("k".into(), "s".into(), Some("pp".into()));
385            save_builder_config_to_keychain(&config_with).unwrap();
386
387            // Verify passphrase is present
388            assert!(keychain::get(KEYCHAIN_SERVICE, "passphrase").is_ok());
389
390            // Overwrite with None passphrase — should delete the stale entry
391            let config_without = BuilderConfig::new("k".into(), "s".into(), None);
392            save_builder_config_to_keychain(&config_without).unwrap();
393
394            // Verify passphrase has been removed
395            let result = keychain::get(KEYCHAIN_SERVICE, "passphrase");
396            assert!(
397                matches!(result, Err(polyoxide_core::KeychainError::NotFound { .. })),
398                "Expected passphrase to be deleted, got: {result:?}"
399            );
400
401            // And from_keychain should load account without passphrase in config
402            let account = BuilderAccount::from_keychain().unwrap();
403            if let Some(AuthConfig::Builder(bc)) = account.auth_config() {
404                assert!(
405                    bc.passphrase.is_none(),
406                    "Expected passphrase=None after clearing"
407                );
408            } else {
409                panic!("Expected Builder auth config");
410            }
411
412            // Cleanup
413            BuilderAccount::delete_from_keychain().unwrap();
414        }
415
416        #[test]
417        #[ignore] // Requires OS keychain daemon
418        fn relayer_api_key_keychain_roundtrip() {
419            use polyoxide_core::keychain;
420
421            // Store relayer API key credentials
422            save_private_key_to_keychain(TEST_PRIVATE_KEY).unwrap();
423            keychain::set(KEYCHAIN_SERVICE, "relayer_api_key", "test-rk").unwrap();
424            keychain::set(KEYCHAIN_SERVICE, "relayer_api_key_address", "0xaddr").unwrap();
425
426            let account = BuilderAccount::from_keychain_relayer_api_key().unwrap();
427            assert_eq!(
428                account.address(),
429                BuilderAccount::new(TEST_PRIVATE_KEY, None)
430                    .unwrap()
431                    .address()
432            );
433            assert!(matches!(
434                account.auth_config(),
435                Some(AuthConfig::RelayerApiKey(_))
436            ));
437
438            // Cleanup
439            BuilderAccount::delete_from_keychain().unwrap();
440        }
441    }
442
443    #[test]
444    fn test_with_auth_config_invalid_private_key() {
445        let result = BuilderAccount::with_auth_config("not_a_valid_key", None);
446        assert!(result.is_err());
447        match result.unwrap_err() {
448            RelayError::Signer(msg) => {
449                assert!(
450                    msg.contains("Failed to parse private key"),
451                    "unexpected: {msg}"
452                );
453            }
454            other => panic!("Expected Signer error, got: {other:?}"),
455        }
456    }
457}