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        Self::from_keychain_in_service(KEYCHAIN_SERVICE)
103    }
104
105    /// Implementation of [`BuilderAccount::from_keychain`] parameterized by
106    /// service name. Tests pass an isolated service so they never read the real
107    /// `polyoxide-relay` entries.
108    #[cfg(feature = "keychain")]
109    fn from_keychain_in_service(service: &str) -> Result<Self, RelayError> {
110        use polyoxide_core::keychain;
111
112        let private_key = keychain::get(service, "private_key")
113            .map_err(|e| RelayError::Api(format!("Keychain error for private_key: {e}")))?;
114
115        let config = match keychain::get(service, "api_key") {
116            Ok(key) => {
117                let secret = keychain::get(service, "api_secret")
118                    .map_err(|e| RelayError::Api(format!("Keychain error for api_secret: {e}")))?;
119                let passphrase = keychain::get(service, "passphrase").ok();
120                Some(BuilderConfig::new(key, secret, passphrase))
121            }
122            Err(polyoxide_core::KeychainError::NotFound { .. }) => None,
123            Err(e) => return Err(RelayError::Api(format!("Keychain error: {e}"))),
124        };
125
126        Self::new(private_key, config)
127    }
128
129    /// Load account from the OS keychain with relayer API key credentials.
130    ///
131    /// Reads from the `polyoxide-relay` keychain service:
132    /// - `private_key`: Hex-encoded private key
133    /// - `relayer_api_key`: Static relayer API key
134    /// - `relayer_api_key_address`: On-chain address for the relayer API key
135    #[cfg(feature = "keychain")]
136    pub fn from_keychain_relayer_api_key() -> Result<Self, RelayError> {
137        Self::from_keychain_relayer_api_key_in_service(KEYCHAIN_SERVICE)
138    }
139
140    /// Implementation of [`BuilderAccount::from_keychain_relayer_api_key`]
141    /// parameterized by service name. Tests pass an isolated service so they
142    /// never read the real `polyoxide-relay` entries.
143    #[cfg(feature = "keychain")]
144    fn from_keychain_relayer_api_key_in_service(service: &str) -> Result<Self, RelayError> {
145        use polyoxide_core::keychain;
146
147        let private_key = keychain::get(service, "private_key")
148            .map_err(|e| RelayError::Api(format!("Keychain error for private_key: {e}")))?;
149        let key = keychain::get(service, "relayer_api_key")
150            .map_err(|e| RelayError::Api(format!("Keychain error for relayer_api_key: {e}")))?;
151        let address = keychain::get(service, "relayer_api_key_address").map_err(|e| {
152            RelayError::Api(format!("Keychain error for relayer_api_key_address: {e}"))
153        })?;
154
155        Self::with_relayer_api_key(private_key, key, address)
156    }
157
158    /// Delete all credentials from the OS keychain for this service.
159    #[cfg(feature = "keychain")]
160    pub fn delete_from_keychain() -> Result<(), RelayError> {
161        Self::delete_from_keychain_in_service(KEYCHAIN_SERVICE)
162    }
163
164    /// Implementation of [`BuilderAccount::delete_from_keychain`] parameterized
165    /// by service name. Tests pass an isolated service so they never delete the
166    /// real `polyoxide-relay` entries.
167    #[cfg(feature = "keychain")]
168    fn delete_from_keychain_in_service(service: &str) -> Result<(), RelayError> {
169        use polyoxide_core::keychain;
170
171        for key in [
172            "private_key",
173            "api_key",
174            "api_secret",
175            "passphrase",
176            "relayer_api_key",
177            "relayer_api_key_address",
178        ] {
179            keychain::delete(service, key)
180                .map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
181        }
182        Ok(())
183    }
184}
185
186/// Save a private key to the OS keychain under the `polyoxide-relay` service.
187#[cfg(feature = "keychain")]
188pub fn save_private_key_to_keychain(private_key: &str) -> Result<(), RelayError> {
189    save_private_key_to_keychain_in_service(KEYCHAIN_SERVICE, private_key)
190}
191
192/// Implementation of [`save_private_key_to_keychain`] parameterized by service
193/// name. Tests pass an isolated service so they never overwrite the real
194/// `polyoxide-relay` private key.
195#[cfg(feature = "keychain")]
196fn save_private_key_to_keychain_in_service(
197    service: &str,
198    private_key: &str,
199) -> Result<(), RelayError> {
200    polyoxide_core::keychain::set(service, "private_key", private_key)
201        .map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
202    Ok(())
203}
204
205/// Save builder API credentials to the OS keychain under the `polyoxide-relay` service.
206///
207/// When `config.passphrase` is `None`, any previously stored passphrase is deleted
208/// to prevent stale values from persisting.
209#[cfg(feature = "keychain")]
210pub fn save_builder_config_to_keychain(config: &BuilderConfig) -> Result<(), RelayError> {
211    save_builder_config_to_keychain_in_service(KEYCHAIN_SERVICE, config)
212}
213
214/// Implementation of [`save_builder_config_to_keychain`] parameterized by
215/// service name. Tests pass an isolated service so they never overwrite the
216/// real `polyoxide-relay` entries.
217#[cfg(feature = "keychain")]
218fn save_builder_config_to_keychain_in_service(
219    service: &str,
220    config: &BuilderConfig,
221) -> Result<(), RelayError> {
222    use polyoxide_core::keychain;
223
224    keychain::set(service, "api_key", &config.key)
225        .map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
226    keychain::set(service, "api_secret", &config.secret)
227        .map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
228    match &config.passphrase {
229        Some(passphrase) => {
230            keychain::set(service, "passphrase", passphrase)
231                .map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
232        }
233        None => {
234            keychain::delete(service, "passphrase")
235                .map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
236        }
237    }
238    Ok(())
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::config::AuthConfig;
245
246    // A well-known test private key (DO NOT use for real funds)
247    // Address: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 (anvil/hardhat default #0)
248    const TEST_PRIVATE_KEY: &str =
249        "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
250
251    #[test]
252    fn test_new_valid_private_key() {
253        let account = BuilderAccount::new(TEST_PRIVATE_KEY, None);
254        assert!(account.is_ok());
255    }
256
257    #[test]
258    fn test_new_with_0x_prefix() {
259        let key = format!("0x{}", TEST_PRIVATE_KEY);
260        let account = BuilderAccount::new(key, None);
261        // alloy accepts 0x-prefixed keys
262        assert!(account.is_ok());
263    }
264
265    #[test]
266    fn test_new_invalid_private_key() {
267        let result = BuilderAccount::new("not_a_valid_key", None);
268        assert!(result.is_err());
269        let err = result.unwrap_err();
270        match err {
271            RelayError::Signer(msg) => {
272                assert!(
273                    msg.contains("Failed to parse private key"),
274                    "unexpected: {msg}"
275                );
276            }
277            other => panic!("Expected Signer error, got: {other:?}"),
278        }
279    }
280
281    #[test]
282    fn test_new_empty_key() {
283        let result = BuilderAccount::new("", None);
284        assert!(result.is_err());
285    }
286
287    #[test]
288    fn test_address_derivation_deterministic() {
289        let a1 = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
290        let a2 = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
291        assert_eq!(a1.address(), a2.address());
292    }
293
294    #[test]
295    fn test_address_matches_known_value() {
296        // The first anvil/hardhat default account
297        let account = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
298        let expected: Address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
299            .parse()
300            .unwrap();
301        assert_eq!(account.address(), expected);
302    }
303
304    #[test]
305    fn test_debug_redacts_private_key() {
306        let account = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
307        let debug_output = format!("{:?}", account);
308        assert!(
309            debug_output.contains("address"),
310            "Debug should show address, got: {debug_output}"
311        );
312        assert!(
313            !debug_output.contains(TEST_PRIVATE_KEY),
314            "Debug should not contain the private key, got: {debug_output}"
315        );
316    }
317
318    #[test]
319    fn test_config_none() {
320        let account = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
321        assert!(account.auth_config().is_none());
322    }
323
324    #[test]
325    fn test_config_some() {
326        let config = BuilderConfig::new("key".into(), "secret".into(), None);
327        let account = BuilderAccount::new(TEST_PRIVATE_KEY, Some(config)).unwrap();
328        assert!(account.auth_config().is_some());
329    }
330
331    #[test]
332    fn test_with_relayer_api_key() {
333        let account = BuilderAccount::with_relayer_api_key(
334            TEST_PRIVATE_KEY,
335            "my-key".to_string(),
336            "0xaddr".to_string(),
337        )
338        .unwrap();
339        assert!(account.auth_config().is_some());
340        assert!(matches!(
341            account.auth_config(),
342            Some(AuthConfig::RelayerApiKey(_))
343        ));
344    }
345
346    #[test]
347    fn test_new_wraps_builder_config_in_auth_config() {
348        let config = BuilderConfig::new("key".into(), "secret".into(), None);
349        let account = BuilderAccount::new(TEST_PRIVATE_KEY, Some(config)).unwrap();
350        assert!(matches!(
351            account.auth_config(),
352            Some(AuthConfig::Builder(_))
353        ));
354    }
355
356    #[test]
357    fn test_with_auth_config_none() {
358        let account = BuilderAccount::with_auth_config(TEST_PRIVATE_KEY, None).unwrap();
359        assert!(account.auth_config().is_none());
360    }
361
362    #[test]
363    fn test_with_auth_config_relayer_api_key_variant() {
364        let relayer =
365            crate::config::RelayerApiKeyConfig::new("rk".into(), "0xaddr".into()).unwrap();
366        let auth = AuthConfig::RelayerApiKey(relayer);
367        let account = BuilderAccount::with_auth_config(TEST_PRIVATE_KEY, Some(auth)).unwrap();
368        assert!(matches!(
369            account.auth_config(),
370            Some(AuthConfig::RelayerApiKey(_))
371        ));
372    }
373
374    #[cfg(feature = "keychain")]
375    mod keychain_tests {
376        use super::*;
377
378        const TEST_PRIVATE_KEY: &str =
379            "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
380
381        // Each test uses its OWN isolated keychain service (never the real
382        // `polyoxide-relay` service), so it can neither read, overwrite, nor
383        // delete a developer's stored credentials. Because no two tests share a
384        // service, they also can't clobber each other's entries — making them
385        // safe to run concurrently without serialization.
386        #[test]
387        #[ignore] // Requires OS keychain daemon
388        fn builder_account_keychain_roundtrip() {
389            const SERVICE: &str = "polyoxide-relay-test-builder-roundtrip";
390
391            save_private_key_to_keychain_in_service(SERVICE, TEST_PRIVATE_KEY).unwrap();
392            let config = BuilderConfig::new("rk".into(), "rs".into(), Some("rp".into()));
393            save_builder_config_to_keychain_in_service(SERVICE, &config).unwrap();
394
395            let account = BuilderAccount::from_keychain_in_service(SERVICE).unwrap();
396            assert_eq!(
397                account.address(),
398                BuilderAccount::new(TEST_PRIVATE_KEY, None)
399                    .unwrap()
400                    .address()
401            );
402            assert!(account.auth_config().is_some());
403
404            // Cleanup
405            BuilderAccount::delete_from_keychain_in_service(SERVICE).unwrap();
406        }
407
408        #[test]
409        #[ignore] // Requires OS keychain daemon
410        fn builder_account_keychain_no_config() {
411            use polyoxide_core::keychain;
412            const SERVICE: &str = "polyoxide-relay-test-no-config";
413
414            // Clear any leftover builder config entries so from_keychain()
415            // exercises the "no api_key found" path.
416            let _ = keychain::delete(SERVICE, "api_key");
417            let _ = keychain::delete(SERVICE, "api_secret");
418            let _ = keychain::delete(SERVICE, "passphrase");
419
420            save_private_key_to_keychain_in_service(SERVICE, TEST_PRIVATE_KEY).unwrap();
421
422            let account = BuilderAccount::from_keychain_in_service(SERVICE).unwrap();
423            assert!(
424                account.auth_config().is_none(),
425                "Expected no auth config when api_key is absent"
426            );
427
428            // Cleanup
429            let _ = keychain::delete(SERVICE, "private_key");
430        }
431
432        #[test]
433        #[ignore] // Requires OS keychain daemon
434        fn save_builder_config_none_passphrase_clears_stale() {
435            use polyoxide_core::keychain;
436            const SERVICE: &str = "polyoxide-relay-test-clears-stale";
437
438            // Store config WITH passphrase
439            save_private_key_to_keychain_in_service(SERVICE, TEST_PRIVATE_KEY).unwrap();
440            let config_with = BuilderConfig::new("k".into(), "s".into(), Some("pp".into()));
441            save_builder_config_to_keychain_in_service(SERVICE, &config_with).unwrap();
442
443            // Verify passphrase is present
444            assert!(keychain::get(SERVICE, "passphrase").is_ok());
445
446            // Overwrite with None passphrase — should delete the stale entry
447            let config_without = BuilderConfig::new("k".into(), "s".into(), None);
448            save_builder_config_to_keychain_in_service(SERVICE, &config_without).unwrap();
449
450            // Verify passphrase has been removed
451            let result = keychain::get(SERVICE, "passphrase");
452            assert!(
453                matches!(result, Err(polyoxide_core::KeychainError::NotFound { .. })),
454                "Expected passphrase to be deleted, got: {result:?}"
455            );
456
457            // And from_keychain should load account without passphrase in config
458            let account = BuilderAccount::from_keychain_in_service(SERVICE).unwrap();
459            if let Some(AuthConfig::Builder(bc)) = account.auth_config() {
460                assert!(
461                    bc.passphrase.is_none(),
462                    "Expected passphrase=None after clearing"
463                );
464            } else {
465                panic!("Expected Builder auth config");
466            }
467
468            // Cleanup
469            BuilderAccount::delete_from_keychain_in_service(SERVICE).unwrap();
470        }
471
472        #[test]
473        #[ignore] // Requires OS keychain daemon
474        fn relayer_api_key_keychain_roundtrip() {
475            use polyoxide_core::keychain;
476            const SERVICE: &str = "polyoxide-relay-test-relayer-key";
477
478            // Store relayer API key credentials
479            save_private_key_to_keychain_in_service(SERVICE, TEST_PRIVATE_KEY).unwrap();
480            keychain::set(SERVICE, "relayer_api_key", "test-rk").unwrap();
481            keychain::set(SERVICE, "relayer_api_key_address", "0xaddr").unwrap();
482
483            let account =
484                BuilderAccount::from_keychain_relayer_api_key_in_service(SERVICE).unwrap();
485            assert_eq!(
486                account.address(),
487                BuilderAccount::new(TEST_PRIVATE_KEY, None)
488                    .unwrap()
489                    .address()
490            );
491            assert!(matches!(
492                account.auth_config(),
493                Some(AuthConfig::RelayerApiKey(_))
494            ));
495
496            // Cleanup
497            BuilderAccount::delete_from_keychain_in_service(SERVICE).unwrap();
498        }
499    }
500
501    #[test]
502    fn test_with_auth_config_invalid_private_key() {
503        let result = BuilderAccount::with_auth_config("not_a_valid_key", None);
504        assert!(result.is_err());
505        match result.unwrap_err() {
506            RelayError::Signer(msg) => {
507                assert!(
508                    msg.contains("Failed to parse private key"),
509                    "unexpected: {msg}"
510                );
511            }
512            other => panic!("Expected Signer error, got: {other:?}"),
513        }
514    }
515}