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