1mod credentials;
7mod signer;
8mod wallet;
9
10use std::path::Path;
11
12use alloy::primitives::Address;
13pub use credentials::Credentials;
14use serde::{Deserialize, Serialize};
15pub use signer::Signer;
16pub use wallet::Wallet;
17
18use crate::{
19 core::eip712::{sign_clob_auth, sign_order},
20 error::ClobError,
21 types::{Order, SignedOrder},
22};
23
24pub mod env {
26 pub const PRIVATE_KEY: &str = "POLYMARKET_PRIVATE_KEY";
28 pub const API_KEY: &str = "POLYMARKET_API_KEY";
30 pub const API_SECRET: &str = "POLYMARKET_API_SECRET";
32 pub const API_PASSPHRASE: &str = "POLYMARKET_API_PASSPHRASE";
34}
35
36#[cfg(feature = "keychain")]
38pub const KEYCHAIN_SERVICE: &str = "polyoxide-clob";
39
40#[derive(Clone, Serialize, Deserialize)]
42pub struct AccountConfig {
43 pub private_key: String,
44 #[serde(flatten)]
45 pub credentials: Credentials,
46}
47
48impl std::fmt::Debug for AccountConfig {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct("AccountConfig")
51 .field("private_key", &"[REDACTED]")
52 .field("credentials", &self.credentials)
53 .finish()
54 }
55}
56
57#[derive(Clone, Debug)]
80pub struct Account {
81 wallet: Wallet,
82 credentials: Credentials,
83 signer: Signer,
84}
85
86impl Account {
87 pub fn new(
109 private_key: impl Into<String>,
110 credentials: Credentials,
111 ) -> Result<Self, ClobError> {
112 let wallet = Wallet::from_private_key(&private_key.into())?;
113 let signer = Signer::new(&credentials.secret);
114
115 Ok(Self {
116 wallet,
117 credentials,
118 signer,
119 })
120 }
121
122 pub fn from_env() -> Result<Self, ClobError> {
139 let private_key = std::env::var(env::PRIVATE_KEY).map_err(|_| {
140 ClobError::validation(format!(
141 "Missing environment variable: {}",
142 env::PRIVATE_KEY
143 ))
144 })?;
145
146 let credentials = Credentials {
147 key: std::env::var(env::API_KEY).map_err(|_| {
148 ClobError::validation(format!("Missing environment variable: {}", env::API_KEY))
149 })?,
150 secret: std::env::var(env::API_SECRET).map_err(|_| {
151 ClobError::validation(format!("Missing environment variable: {}", env::API_SECRET))
152 })?,
153 passphrase: std::env::var(env::API_PASSPHRASE).map_err(|_| {
154 ClobError::validation(format!(
155 "Missing environment variable: {}",
156 env::API_PASSPHRASE
157 ))
158 })?,
159 };
160
161 Self::new(private_key, credentials)
162 }
163
164 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ClobError> {
185 let path = path.as_ref();
186 let content = std::fs::read_to_string(path).map_err(|e| {
187 ClobError::validation(format!(
188 "Failed to read config file {}: {}",
189 path.display(),
190 e
191 ))
192 })?;
193
194 Self::from_json(&content)
195 }
196
197 pub fn from_json(json: &str) -> Result<Self, ClobError> {
215 let config: AccountConfig = serde_json::from_str(json)
216 .map_err(|e| ClobError::validation(format!("Failed to parse JSON config: {}", e)))?;
217
218 Self::new(config.private_key, config.credentials)
219 }
220
221 #[cfg(feature = "keychain")]
240 pub fn from_keychain() -> Result<Self, ClobError> {
241 Self::from_keychain_in_service(KEYCHAIN_SERVICE)
242 }
243
244 #[cfg(feature = "keychain")]
248 fn from_keychain_in_service(service: &str) -> Result<Self, ClobError> {
249 use polyoxide_core::keychain;
250
251 let private_key = keychain::get(service, "private_key")
252 .map_err(|e| ClobError::validation(format!("Keychain error for private_key: {e}")))?;
253
254 let credentials = Credentials {
255 key: keychain::get(service, "api_key")
256 .map_err(|e| ClobError::validation(format!("Keychain error for api_key: {e}")))?,
257 secret: keychain::get(service, "api_secret").map_err(|e| {
258 ClobError::validation(format!("Keychain error for api_secret: {e}"))
259 })?,
260 passphrase: keychain::get(service, "api_passphrase").map_err(|e| {
261 ClobError::validation(format!("Keychain error for api_passphrase: {e}"))
262 })?,
263 };
264
265 Self::new(private_key, credentials)
266 }
267
268 #[cfg(feature = "keychain")]
277 pub fn save_to_keychain(&self) -> Result<(), ClobError> {
278 self.save_to_keychain_in_service(KEYCHAIN_SERVICE)
279 }
280
281 #[cfg(feature = "keychain")]
285 fn save_to_keychain_in_service(&self, service: &str) -> Result<(), ClobError> {
286 use polyoxide_core::keychain;
287
288 keychain::set(service, "api_key", &self.credentials.key)
289 .map_err(|e| ClobError::validation(format!("Keychain error: {e}")))?;
290 keychain::set(service, "api_secret", &self.credentials.secret)
291 .map_err(|e| ClobError::validation(format!("Keychain error: {e}")))?;
292 keychain::set(service, "api_passphrase", &self.credentials.passphrase)
293 .map_err(|e| ClobError::validation(format!("Keychain error: {e}")))?;
294 Ok(())
295 }
296
297 #[cfg(feature = "keychain")]
301 pub fn delete_from_keychain() -> Result<(), ClobError> {
302 Self::delete_from_keychain_in_service(KEYCHAIN_SERVICE)
303 }
304
305 #[cfg(feature = "keychain")]
309 fn delete_from_keychain_in_service(service: &str) -> Result<(), ClobError> {
310 use polyoxide_core::keychain;
311
312 for key in ["private_key", "api_key", "api_secret", "api_passphrase"] {
313 keychain::delete(service, key)
314 .map_err(|e| ClobError::validation(format!("Keychain error: {e}")))?;
315 }
316 Ok(())
317 }
318
319 pub fn address(&self) -> Address {
321 self.wallet.address()
322 }
323
324 pub fn wallet(&self) -> &Wallet {
326 &self.wallet
327 }
328
329 pub fn credentials(&self) -> &Credentials {
331 &self.credentials
332 }
333
334 pub fn signer(&self) -> &Signer {
336 &self.signer
337 }
338
339 pub async fn sign_order(&self, order: &Order, chain_id: u64) -> Result<SignedOrder, ClobError> {
358 let signature = sign_order(order, self.wallet.signer(), chain_id).await?;
359
360 Ok(SignedOrder {
361 order: order.clone(),
362 signature,
363 })
364 }
365
366 pub async fn sign_clob_auth(
374 &self,
375 chain_id: u64,
376 timestamp: u64,
377 nonce: u32,
378 ) -> Result<String, ClobError> {
379 sign_clob_auth(self.wallet.signer(), chain_id, timestamp, nonce).await
380 }
381
382 pub fn sign_l2_request(
391 &self,
392 timestamp: u64,
393 method: &str,
394 path: &str,
395 body: Option<&str>,
396 ) -> Result<String, ClobError> {
397 let message = Signer::create_message(timestamp, method, path, body);
398 self.signer.sign(&message)
399 }
400}
401
402#[cfg(feature = "keychain")]
409pub fn save_private_key_to_keychain(private_key: &str) -> Result<(), ClobError> {
410 save_private_key_to_keychain_in_service(KEYCHAIN_SERVICE, private_key)
411}
412
413#[cfg(feature = "keychain")]
417fn save_private_key_to_keychain_in_service(
418 service: &str,
419 private_key: &str,
420) -> Result<(), ClobError> {
421 polyoxide_core::keychain::set(service, "private_key", private_key)
422 .map_err(|e| ClobError::validation(format!("Keychain error: {e}")))?;
423 Ok(())
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
431 fn test_from_json() {
432 let json = r#"{
433 "private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
434 "key": "test_key",
435 "secret": "c2VjcmV0",
436 "passphrase": "test_pass"
437 }"#;
438
439 let account = Account::from_json(json).unwrap();
440 assert_eq!(account.credentials().key, "test_key");
441 assert_eq!(account.credentials().passphrase, "test_pass");
442 }
443
444 #[test]
445 fn test_account_config_debug_redacts_private_key() {
446 let config = AccountConfig {
447 private_key: "0xdeadbeef_super_secret_key".to_string(),
448 credentials: Credentials {
449 key: "api_key".to_string(),
450 secret: "api_secret".to_string(),
451 passphrase: "pass".to_string(),
452 },
453 };
454 let debug_output = format!("{:?}", config);
455 assert!(
456 debug_output.contains("[REDACTED]"),
457 "Debug should contain [REDACTED], got: {debug_output}"
458 );
459 assert!(
460 !debug_output.contains("deadbeef"),
461 "Debug should not contain the private key, got: {debug_output}"
462 );
463 }
464
465 #[cfg(feature = "keychain")]
466 mod keychain_tests {
467 use super::*;
468
469 #[test]
475 #[ignore] fn keychain_roundtrip() {
477 const SERVICE: &str = "polyoxide-clob-test-account-roundtrip";
478
479 let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
480 let credentials = Credentials {
481 key: "test_keychain_key".to_string(),
482 secret: "c2VjcmV0".to_string(),
483 passphrase: "test_keychain_pass".to_string(),
484 };
485
486 save_private_key_to_keychain_in_service(SERVICE, private_key).unwrap();
487 let account = Account::new(private_key, credentials).unwrap();
488 account.save_to_keychain_in_service(SERVICE).unwrap();
489
490 let loaded = Account::from_keychain_in_service(SERVICE).unwrap();
492 assert_eq!(loaded.credentials().key, "test_keychain_key");
493 assert_eq!(loaded.credentials().secret, "c2VjcmV0");
494 assert_eq!(loaded.credentials().passphrase, "test_keychain_pass");
495 assert_eq!(loaded.address(), account.address());
496
497 Account::delete_from_keychain_in_service(SERVICE).unwrap();
499 }
500
501 #[test]
502 #[ignore] fn keychain_delete_removes_all_entries() {
504 const SERVICE: &str = "polyoxide-clob-test-account-delete";
505
506 let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
507 let credentials = Credentials {
508 key: "del_test_key".to_string(),
509 secret: "c2VjcmV0".to_string(),
510 passphrase: "del_test_pass".to_string(),
511 };
512
513 save_private_key_to_keychain_in_service(SERVICE, private_key).unwrap();
515 Account::new(private_key, credentials)
516 .unwrap()
517 .save_to_keychain_in_service(SERVICE)
518 .unwrap();
519 Account::delete_from_keychain_in_service(SERVICE).unwrap();
520
521 let err = Account::from_keychain_in_service(SERVICE).unwrap_err();
523 let msg = err.to_string();
524 assert!(
525 msg.contains("Keychain entry not found"),
526 "Expected NotFound after delete, got: {msg}"
527 );
528 }
529 }
530
531 #[test]
532 fn test_sign_l2_request() {
533 let json = r#"{
534 "private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
535 "key": "test_key",
536 "secret": "c2VjcmV0",
537 "passphrase": "test_pass"
538 }"#;
539
540 let account = Account::from_json(json).unwrap();
541 let signature = account
542 .sign_l2_request(1234567890, "GET", "/api/test", None)
543 .unwrap();
544
545 assert!(!signature.contains('+'));
547 assert!(!signature.contains('/'));
548 }
549}