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 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 #[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 #[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#[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#[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 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 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 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] 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 BuilderAccount::delete_from_keychain().unwrap();
353 }
354
355 #[test]
356 #[ignore] fn builder_account_keychain_no_config() {
358 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 let _ = keychain::delete(KEYCHAIN_SERVICE, "private_key");
375 }
376
377 #[test]
378 #[ignore] fn save_builder_config_none_passphrase_clears_stale() {
380 use polyoxide_core::keychain;
381
382 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 assert!(keychain::get(KEYCHAIN_SERVICE, "passphrase").is_ok());
389
390 let config_without = BuilderConfig::new("k".into(), "s".into(), None);
392 save_builder_config_to_keychain(&config_without).unwrap();
393
394 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 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 BuilderAccount::delete_from_keychain().unwrap();
414 }
415
416 #[test]
417 #[ignore] fn relayer_api_key_keychain_roundtrip() {
419 use polyoxide_core::keychain;
420
421 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 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}