Skip to main content

polyoxide_clob/account/
mod.rs

1//! Account module for credential management and signing operations.
2//!
3//! This module provides a unified abstraction for managing Polymarket CLOB authentication,
4//! including wallet management, API credentials, and signing operations.
5
6mod 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
24/// Environment variable names for account configuration
25pub mod env {
26    /// Environment variable holding the hex-encoded private key.
27    pub const PRIVATE_KEY: &str = "POLYMARKET_PRIVATE_KEY";
28    /// Environment variable holding the L2 API key.
29    pub const API_KEY: &str = "POLYMARKET_API_KEY";
30    /// Environment variable holding the L2 API secret (base64 encoded).
31    pub const API_SECRET: &str = "POLYMARKET_API_SECRET";
32    /// Environment variable holding the L2 API passphrase.
33    pub const API_PASSPHRASE: &str = "POLYMARKET_API_PASSPHRASE";
34}
35
36/// Keychain service name for CLOB credentials.
37#[cfg(feature = "keychain")]
38pub const KEYCHAIN_SERVICE: &str = "polyoxide-clob";
39
40/// Account configuration for file-based loading
41#[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/// Unified account primitive for credential management and signing operations.
58///
59/// `Account` combines wallet (private key), API credentials, and signing capabilities
60/// into a single abstraction. It provides factory methods for loading credentials from
61/// various sources (environment variables, files) and handles both EIP-712 order signing
62/// and HMAC-based L2 API authentication.
63///
64/// # Example
65///
66/// ```no_run
67/// use polyoxide_clob::Account;
68///
69/// // Load from environment variables
70/// let account = Account::from_env()?;
71///
72/// // Or load from a JSON file
73/// let account = Account::from_file("config/account.json")?;
74///
75/// // Get the wallet address
76/// println!("Address: {:?}", account.address());
77/// # Ok::<(), polyoxide_clob::ClobError>(())
78/// ```
79#[derive(Clone, Debug)]
80pub struct Account {
81    wallet: Wallet,
82    credentials: Credentials,
83    signer: Signer,
84}
85
86impl Account {
87    /// Create a new account from private key and credentials.
88    ///
89    /// # Arguments
90    ///
91    /// * `private_key` - Hex-encoded private key (with or without 0x prefix)
92    /// * `credentials` - API credentials for L2 authentication
93    ///
94    /// # Example
95    ///
96    /// ```no_run
97    /// use polyoxide_clob::{Account, Credentials};
98    ///
99    /// let credentials = Credentials {
100    ///     key: "api_key".to_string(),
101    ///     secret: "api_secret".to_string(),
102    ///     passphrase: "passphrase".to_string(),
103    /// };
104    ///
105    /// let account = Account::new("0x...", credentials)?;
106    /// # Ok::<(), polyoxide_clob::ClobError>(())
107    /// ```
108    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    /// Load account from environment variables.
123    ///
124    /// Reads the following environment variables:
125    /// - `POLYMARKET_PRIVATE_KEY`: Hex-encoded private key
126    /// - `POLYMARKET_API_KEY`: API key
127    /// - `POLYMARKET_API_SECRET`: API secret (base64 encoded)
128    /// - `POLYMARKET_API_PASSPHRASE`: API passphrase
129    ///
130    /// # Example
131    ///
132    /// ```no_run
133    /// use polyoxide_clob::Account;
134    ///
135    /// let account = Account::from_env()?;
136    /// # Ok::<(), polyoxide_clob::ClobError>(())
137    /// ```
138    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    /// Load account from a JSON configuration file.
165    ///
166    /// The file should contain:
167    /// ```json
168    /// {
169    ///     "private_key": "0x...",
170    ///     "key": "api_key",
171    ///     "secret": "api_secret",
172    ///     "passphrase": "passphrase"
173    /// }
174    /// ```
175    ///
176    /// # Example
177    ///
178    /// ```no_run
179    /// use polyoxide_clob::Account;
180    ///
181    /// let account = Account::from_file("config/account.json")?;
182    /// # Ok::<(), polyoxide_clob::ClobError>(())
183    /// ```
184    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    /// Load account from a JSON string.
198    ///
199    /// # Example
200    ///
201    /// ```no_run
202    /// use polyoxide_clob::Account;
203    ///
204    /// let json = r#"{
205    ///     "private_key": "0x...",
206    ///     "key": "api_key",
207    ///     "secret": "api_secret",
208    ///     "passphrase": "passphrase"
209    /// }"#;
210    ///
211    /// let account = Account::from_json(json)?;
212    /// # Ok::<(), polyoxide_clob::ClobError>(())
213    /// ```
214    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    /// Load account from the OS keychain.
222    ///
223    /// Reads from the `polyoxide-clob` keychain service:
224    /// - `private_key`: Hex-encoded private key
225    /// - `api_key`: API key
226    /// - `api_secret`: API secret (base64 encoded)
227    /// - `api_passphrase`: API passphrase
228    ///
229    /// Requires the `keychain` crate feature.
230    ///
231    /// # Example
232    ///
233    /// ```no_run
234    /// use polyoxide_clob::Account;
235    ///
236    /// let account = Account::from_keychain()?;
237    /// # Ok::<(), polyoxide_clob::ClobError>(())
238    /// ```
239    #[cfg(feature = "keychain")]
240    pub fn from_keychain() -> Result<Self, ClobError> {
241        Self::from_keychain_in_service(KEYCHAIN_SERVICE)
242    }
243
244    /// Implementation of [`Account::from_keychain`] parameterized by service
245    /// name. Tests pass an isolated service so they never read the real
246    /// `polyoxide-clob` entries.
247    #[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    /// Save L2 API credentials to the OS keychain.
269    ///
270    /// Stores `api_key`, `api_secret`, and `api_passphrase` in the `polyoxide-clob`
271    /// keychain service. Does **not** store the private key (it is discarded after
272    /// parsing during construction). Use [`save_private_key_to_keychain`] to store
273    /// the private key before constructing an `Account`.
274    ///
275    /// Requires the `keychain` crate feature.
276    #[cfg(feature = "keychain")]
277    pub fn save_to_keychain(&self) -> Result<(), ClobError> {
278        self.save_to_keychain_in_service(KEYCHAIN_SERVICE)
279    }
280
281    /// Implementation of [`Account::save_to_keychain`] parameterized by service
282    /// name. Tests pass an isolated service so they never overwrite the real
283    /// `polyoxide-clob` entries.
284    #[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    /// Delete L2 API credentials and private key from the OS keychain.
298    ///
299    /// Requires the `keychain` crate feature.
300    #[cfg(feature = "keychain")]
301    pub fn delete_from_keychain() -> Result<(), ClobError> {
302        Self::delete_from_keychain_in_service(KEYCHAIN_SERVICE)
303    }
304
305    /// Implementation of [`Account::delete_from_keychain`] parameterized by
306    /// service name. Tests pass an isolated service so they never delete the
307    /// real `polyoxide-clob` entries.
308    #[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    /// Get the wallet address.
320    pub fn address(&self) -> Address {
321        self.wallet.address()
322    }
323
324    /// Get a reference to the wallet.
325    pub fn wallet(&self) -> &Wallet {
326        &self.wallet
327    }
328
329    /// Get a reference to the credentials.
330    pub fn credentials(&self) -> &Credentials {
331        &self.credentials
332    }
333
334    /// Get a reference to the HMAC signer.
335    pub fn signer(&self) -> &Signer {
336        &self.signer
337    }
338
339    /// Sign an order using EIP-712.
340    ///
341    /// # Arguments
342    ///
343    /// * `order` - The unsigned order to sign
344    /// * `chain_id` - The chain ID for EIP-712 domain
345    ///
346    /// # Example
347    ///
348    /// ```no_run
349    /// use polyoxide_clob::{Account, Order};
350    ///
351    /// async fn example(account: &Account, order: &Order) -> Result<(), Box<dyn std::error::Error>> {
352    ///     let signed_order = account.sign_order(order, 137).await?;
353    ///     println!("Signature: {}", signed_order.signature);
354    ///     Ok(())
355    /// }
356    /// ```
357    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    /// Sign a CLOB authentication message for API key creation (L1 auth).
367    ///
368    /// # Arguments
369    ///
370    /// * `chain_id` - The chain ID for EIP-712 domain
371    /// * `timestamp` - Unix timestamp in seconds
372    /// * `nonce` - Random nonce value
373    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    /// Sign an L2 API request message using HMAC.
383    ///
384    /// # Arguments
385    ///
386    /// * `timestamp` - Unix timestamp in seconds
387    /// * `method` - HTTP method (GET, POST, DELETE)
388    /// * `path` - Request path (e.g., "/order")
389    /// * `body` - Optional request body
390    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/// Save a private key to the OS keychain under the `polyoxide-clob` service.
403///
404/// Call this before [`Account::new`] if you want the private key persisted in the
405/// keychain, since `Account` discards the raw key string after parsing.
406///
407/// Requires the `keychain` crate feature.
408#[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/// Implementation of [`save_private_key_to_keychain`] parameterized by service
414/// name. Tests pass an isolated service so they never overwrite the real
415/// `polyoxide-clob` private key.
416#[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        // Each test uses its OWN isolated keychain service (never the real
470        // `polyoxide-clob` service), so it can neither read, overwrite, nor
471        // delete a developer's stored credentials. Because no two tests share a
472        // service, they also can't clobber each other's entries — making them
473        // safe to run concurrently without serialization.
474        #[test]
475        #[ignore] // Requires OS keychain daemon — run locally with `-- --ignored`
476        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            // Load back
491            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            // Cleanup
498            Account::delete_from_keychain_in_service(SERVICE).unwrap();
499        }
500
501        #[test]
502        #[ignore] // Requires OS keychain daemon
503        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            // Store then delete
514            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            // Verify all entries are gone
522            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        // Should be URL-safe base64
546        assert!(!signature.contains('+'));
547        assert!(!signature.contains('/'));
548    }
549}