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}