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