1use crate::config::{AuthConfig, BuilderConfig, RelayerApiKeyConfig};
2use crate::error::RelayError;
3use alloy::primitives::Address;
4use alloy::signers::local::PrivateKeySigner;
5
6#[cfg(feature = "keychain")]
8pub const KEYCHAIN_SERVICE: &str = "polyoxide-relay";
9
10#[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 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 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 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 pub fn address(&self) -> Address {
80 self.signer.address()
81 }
82
83 pub fn signer(&self) -> &PrivateKeySigner {
85 &self.signer
86 }
87
88 pub fn auth_config(&self) -> Option<&AuthConfig> {
90 self.config.as_ref()
91 }
92
93 #[cfg(feature = "keychain")]
101 pub fn from_keychain() -> Result<Self, RelayError> {
102 Self::from_keychain_in_service(KEYCHAIN_SERVICE)
103 }
104
105 #[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 #[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 #[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 #[cfg(feature = "keychain")]
160 pub fn delete_from_keychain() -> Result<(), RelayError> {
161 Self::delete_from_keychain_in_service(KEYCHAIN_SERVICE)
162 }
163
164 #[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#[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#[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#[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#[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 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 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 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 #[test]
387 #[ignore] 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 BuilderAccount::delete_from_keychain_in_service(SERVICE).unwrap();
406 }
407
408 #[test]
409 #[ignore] fn builder_account_keychain_no_config() {
411 use polyoxide_core::keychain;
412 const SERVICE: &str = "polyoxide-relay-test-no-config";
413
414 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 let _ = keychain::delete(SERVICE, "private_key");
430 }
431
432 #[test]
433 #[ignore] fn save_builder_config_none_passphrase_clears_stale() {
435 use polyoxide_core::keychain;
436 const SERVICE: &str = "polyoxide-relay-test-clears-stale";
437
438 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 assert!(keychain::get(SERVICE, "passphrase").is_ok());
445
446 let config_without = BuilderConfig::new("k".into(), "s".into(), None);
448 save_builder_config_to_keychain_in_service(SERVICE, &config_without).unwrap();
449
450 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 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 BuilderAccount::delete_from_keychain_in_service(SERVICE).unwrap();
470 }
471
472 #[test]
473 #[ignore] fn relayer_api_key_keychain_roundtrip() {
475 use polyoxide_core::keychain;
476 const SERVICE: &str = "polyoxide-relay-test-relayer-key";
477
478 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 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}