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    pub const PRIVATE_KEY: &str = "POLYMARKET_PRIVATE_KEY";
27    pub const API_KEY: &str = "POLYMARKET_API_KEY";
28    pub const API_SECRET: &str = "POLYMARKET_API_SECRET";
29    pub const API_PASSPHRASE: &str = "POLYMARKET_API_PASSPHRASE";
30}
31
32/// Account configuration for file-based loading
33#[derive(Clone, Serialize, Deserialize)]
34pub struct AccountConfig {
35    pub private_key: String,
36    #[serde(flatten)]
37    pub credentials: Credentials,
38}
39
40impl std::fmt::Debug for AccountConfig {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("AccountConfig")
43            .field("private_key", &"[REDACTED]")
44            .field("credentials", &self.credentials)
45            .finish()
46    }
47}
48
49/// Unified account primitive for credential management and signing operations.
50///
51/// `Account` combines wallet (private key), API credentials, and signing capabilities
52/// into a single abstraction. It provides factory methods for loading credentials from
53/// various sources (environment variables, files) and handles both EIP-712 order signing
54/// and HMAC-based L2 API authentication.
55///
56/// # Example
57///
58/// ```no_run
59/// use polyoxide_clob::Account;
60///
61/// // Load from environment variables
62/// let account = Account::from_env()?;
63///
64/// // Or load from a JSON file
65/// let account = Account::from_file("config/account.json")?;
66///
67/// // Get the wallet address
68/// println!("Address: {:?}", account.address());
69/// # Ok::<(), polyoxide_clob::ClobError>(())
70/// ```
71#[derive(Clone, Debug)]
72pub struct Account {
73    wallet: Wallet,
74    credentials: Credentials,
75    signer: Signer,
76}
77
78impl Account {
79    /// Create a new account from private key and credentials.
80    ///
81    /// # Arguments
82    ///
83    /// * `private_key` - Hex-encoded private key (with or without 0x prefix)
84    /// * `credentials` - API credentials for L2 authentication
85    ///
86    /// # Example
87    ///
88    /// ```no_run
89    /// use polyoxide_clob::{Account, Credentials};
90    ///
91    /// let credentials = Credentials {
92    ///     key: "api_key".to_string(),
93    ///     secret: "api_secret".to_string(),
94    ///     passphrase: "passphrase".to_string(),
95    /// };
96    ///
97    /// let account = Account::new("0x...", credentials)?;
98    /// # Ok::<(), polyoxide_clob::ClobError>(())
99    /// ```
100    pub fn new(
101        private_key: impl Into<String>,
102        credentials: Credentials,
103    ) -> Result<Self, ClobError> {
104        let wallet = Wallet::from_private_key(&private_key.into())?;
105        let signer = Signer::new(&credentials.secret);
106
107        Ok(Self {
108            wallet,
109            credentials,
110            signer,
111        })
112    }
113
114    /// Load account from environment variables.
115    ///
116    /// Reads the following environment variables:
117    /// - `POLYMARKET_PRIVATE_KEY`: Hex-encoded private key
118    /// - `POLYMARKET_API_KEY`: API key
119    /// - `POLYMARKET_API_SECRET`: API secret (base64 encoded)
120    /// - `POLYMARKET_API_PASSPHRASE`: API passphrase
121    ///
122    /// # Example
123    ///
124    /// ```no_run
125    /// use polyoxide_clob::Account;
126    ///
127    /// let account = Account::from_env()?;
128    /// # Ok::<(), polyoxide_clob::ClobError>(())
129    /// ```
130    pub fn from_env() -> Result<Self, ClobError> {
131        let private_key = std::env::var(env::PRIVATE_KEY).map_err(|_| {
132            ClobError::validation(format!(
133                "Missing environment variable: {}",
134                env::PRIVATE_KEY
135            ))
136        })?;
137
138        let credentials = Credentials {
139            key: std::env::var(env::API_KEY).map_err(|_| {
140                ClobError::validation(format!("Missing environment variable: {}", env::API_KEY))
141            })?,
142            secret: std::env::var(env::API_SECRET).map_err(|_| {
143                ClobError::validation(format!("Missing environment variable: {}", env::API_SECRET))
144            })?,
145            passphrase: std::env::var(env::API_PASSPHRASE).map_err(|_| {
146                ClobError::validation(format!(
147                    "Missing environment variable: {}",
148                    env::API_PASSPHRASE
149                ))
150            })?,
151        };
152
153        Self::new(private_key, credentials)
154    }
155
156    /// Load account from a JSON configuration file.
157    ///
158    /// The file should contain:
159    /// ```json
160    /// {
161    ///     "private_key": "0x...",
162    ///     "key": "api_key",
163    ///     "secret": "api_secret",
164    ///     "passphrase": "passphrase"
165    /// }
166    /// ```
167    ///
168    /// # Example
169    ///
170    /// ```no_run
171    /// use polyoxide_clob::Account;
172    ///
173    /// let account = Account::from_file("config/account.json")?;
174    /// # Ok::<(), polyoxide_clob::ClobError>(())
175    /// ```
176    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ClobError> {
177        let path = path.as_ref();
178        let content = std::fs::read_to_string(path).map_err(|e| {
179            ClobError::validation(format!(
180                "Failed to read config file {}: {}",
181                path.display(),
182                e
183            ))
184        })?;
185
186        Self::from_json(&content)
187    }
188
189    /// Load account from a JSON string.
190    ///
191    /// # Example
192    ///
193    /// ```no_run
194    /// use polyoxide_clob::Account;
195    ///
196    /// let json = r#"{
197    ///     "private_key": "0x...",
198    ///     "key": "api_key",
199    ///     "secret": "api_secret",
200    ///     "passphrase": "passphrase"
201    /// }"#;
202    ///
203    /// let account = Account::from_json(json)?;
204    /// # Ok::<(), polyoxide_clob::ClobError>(())
205    /// ```
206    pub fn from_json(json: &str) -> Result<Self, ClobError> {
207        let config: AccountConfig = serde_json::from_str(json)
208            .map_err(|e| ClobError::validation(format!("Failed to parse JSON config: {}", e)))?;
209
210        Self::new(config.private_key, config.credentials)
211    }
212
213    /// Get the wallet address.
214    pub fn address(&self) -> Address {
215        self.wallet.address()
216    }
217
218    /// Get a reference to the wallet.
219    pub fn wallet(&self) -> &Wallet {
220        &self.wallet
221    }
222
223    /// Get a reference to the credentials.
224    pub fn credentials(&self) -> &Credentials {
225        &self.credentials
226    }
227
228    /// Get a reference to the HMAC signer.
229    pub fn signer(&self) -> &Signer {
230        &self.signer
231    }
232
233    /// Sign an order using EIP-712.
234    ///
235    /// # Arguments
236    ///
237    /// * `order` - The unsigned order to sign
238    /// * `chain_id` - The chain ID for EIP-712 domain
239    ///
240    /// # Example
241    ///
242    /// ```no_run
243    /// use polyoxide_clob::{Account, Order};
244    ///
245    /// async fn example(account: &Account, order: &Order) -> Result<(), Box<dyn std::error::Error>> {
246    ///     let signed_order = account.sign_order(order, 137).await?;
247    ///     println!("Signature: {}", signed_order.signature);
248    ///     Ok(())
249    /// }
250    /// ```
251    pub async fn sign_order(&self, order: &Order, chain_id: u64) -> Result<SignedOrder, ClobError> {
252        let signature = sign_order(order, self.wallet.signer(), chain_id).await?;
253
254        Ok(SignedOrder {
255            order: order.clone(),
256            signature,
257        })
258    }
259
260    /// Sign a CLOB authentication message for API key creation (L1 auth).
261    ///
262    /// # Arguments
263    ///
264    /// * `chain_id` - The chain ID for EIP-712 domain
265    /// * `timestamp` - Unix timestamp in seconds
266    /// * `nonce` - Random nonce value
267    pub async fn sign_clob_auth(
268        &self,
269        chain_id: u64,
270        timestamp: u64,
271        nonce: u32,
272    ) -> Result<String, ClobError> {
273        sign_clob_auth(self.wallet.signer(), chain_id, timestamp, nonce).await
274    }
275
276    /// Sign an L2 API request message using HMAC.
277    ///
278    /// # Arguments
279    ///
280    /// * `timestamp` - Unix timestamp in seconds
281    /// * `method` - HTTP method (GET, POST, DELETE)
282    /// * `path` - Request path (e.g., "/order")
283    /// * `body` - Optional request body
284    pub fn sign_l2_request(
285        &self,
286        timestamp: u64,
287        method: &str,
288        path: &str,
289        body: Option<&str>,
290    ) -> Result<String, ClobError> {
291        let message = Signer::create_message(timestamp, method, path, body);
292        self.signer.sign(&message)
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_from_json() {
302        let json = r#"{
303            "private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
304            "key": "test_key",
305            "secret": "c2VjcmV0",
306            "passphrase": "test_pass"
307        }"#;
308
309        let account = Account::from_json(json).unwrap();
310        assert_eq!(account.credentials().key, "test_key");
311        assert_eq!(account.credentials().passphrase, "test_pass");
312    }
313
314    #[test]
315    fn test_account_config_debug_redacts_private_key() {
316        let config = AccountConfig {
317            private_key: "0xdeadbeef_super_secret_key".to_string(),
318            credentials: Credentials {
319                key: "api_key".to_string(),
320                secret: "api_secret".to_string(),
321                passphrase: "pass".to_string(),
322            },
323        };
324        let debug_output = format!("{:?}", config);
325        assert!(
326            debug_output.contains("[REDACTED]"),
327            "Debug should contain [REDACTED], got: {debug_output}"
328        );
329        assert!(
330            !debug_output.contains("deadbeef"),
331            "Debug should not contain the private key, got: {debug_output}"
332        );
333    }
334
335    #[test]
336    fn test_sign_l2_request() {
337        let json = r#"{
338            "private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
339            "key": "test_key",
340            "secret": "c2VjcmV0",
341            "passphrase": "test_pass"
342        }"#;
343
344        let account = Account::from_json(json).unwrap();
345        let signature = account
346            .sign_l2_request(1234567890, "GET", "/api/test", None)
347            .unwrap();
348
349        // Should be URL-safe base64
350        assert!(!signature.contains('+'));
351        assert!(!signature.contains('/'));
352    }
353}