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}