Skip to main content

polyoxide_clob/
client.rs

1use polyoxide_core::{HttpClient, HttpClientBuilder, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS};
2use reqwest::Client;
3use url::Url;
4
5use crate::{
6    account::{Account, Credentials},
7    api::{account::AccountApi, orders::OrderResponse, Health, Markets, Orders},
8    core::chain::Chain,
9    error::ClobError,
10    request::{AuthMode, Request},
11    types::*,
12    utils::{
13        calculate_market_order_amounts, calculate_market_price, calculate_order_amounts,
14        generate_salt,
15    },
16};
17use alloy::primitives::Address;
18use polyoxide_gamma::Gamma;
19
20const DEFAULT_BASE_URL: &str = "https://clob.polymarket.com";
21
22#[derive(Clone)]
23pub struct Clob {
24    pub(crate) client: Client,
25    pub(crate) base_url: Url,
26    pub(crate) chain_id: u64,
27    pub(crate) account: Option<Account>,
28    pub(crate) gamma: Gamma,
29}
30
31impl Clob {
32    /// Create a new CLOB client with default configuration
33    pub fn new(
34        private_key: impl Into<String>,
35        credentials: Credentials,
36    ) -> Result<Self, ClobError> {
37        Self::builder(private_key, credentials)?.build()
38    }
39
40    /// Create a new public CLOB client (read-only)
41    pub fn public() -> Self {
42        ClobBuilder::new().build().unwrap() // unwrap safe because default build never fails
43    }
44
45    /// Create a new CLOB client builder with required authentication
46    pub fn builder(
47        private_key: impl Into<String>,
48        credentials: Credentials,
49    ) -> Result<ClobBuilder, ClobError> {
50        let account = Account::new(private_key, credentials)?;
51        Ok(ClobBuilder::new().with_account(account))
52    }
53
54    /// Create a new CLOB client from an Account
55    pub fn from_account(account: Account) -> Result<Self, ClobError> {
56        ClobBuilder::new().with_account(account).build()
57    }
58
59    /// Get a reference to the account
60    pub fn account(&self) -> Option<&Account> {
61        self.account.as_ref()
62    }
63
64    /// Get markets namespace
65    pub fn markets(&self) -> Markets {
66        Markets {
67            client: self.client.clone(),
68            base_url: self.base_url.clone(),
69            chain_id: self.chain_id,
70        }
71    }
72
73    /// Get health namespace for latency and health checks
74    pub fn health(&self) -> Health {
75        Health {
76            client: self.client.clone(),
77            base_url: self.base_url.clone(),
78        }
79    }
80
81    /// Get orders namespace
82    pub fn orders(&self) -> Result<Orders, ClobError> {
83        let account = self
84            .account
85            .as_ref()
86            .ok_or_else(|| ClobError::validation("Account required for orders API"))?;
87
88        Ok(Orders {
89            client: self.client.clone(),
90            base_url: self.base_url.clone(),
91            wallet: account.wallet().clone(),
92            credentials: account.credentials().clone(),
93            signer: account.signer().clone(),
94            chain_id: self.chain_id,
95        })
96    }
97
98    /// Get account API namespace
99    pub fn account_api(&self) -> Result<AccountApi, ClobError> {
100        let account = self
101            .account
102            .as_ref()
103            .ok_or_else(|| ClobError::validation("Account required for account API"))?;
104
105        Ok(AccountApi {
106            client: self.client.clone(),
107            base_url: self.base_url.clone(),
108            wallet: account.wallet().clone(),
109            credentials: account.credentials().clone(),
110            signer: account.signer().clone(),
111            chain_id: self.chain_id,
112        })
113    }
114
115    /// Create an unsigned order from parameters
116    pub async fn create_order(
117        &self,
118        params: &CreateOrderParams,
119        options: Option<PartialCreateOrderOptions>,
120    ) -> Result<Order, ClobError> {
121        let account = self
122            .account
123            .as_ref()
124            .ok_or_else(|| ClobError::validation("Account required to create order"))?;
125
126        params.validate()?;
127
128        // Fetch or use provided neg_risk status
129        let neg_risk = if let Some(neg_risk) = options.and_then(|o| o.neg_risk) {
130            neg_risk
131        } else {
132            let neg_risk_resp = self
133                .markets()
134                .neg_risk(params.token_id.clone())
135                .send()
136                .await?;
137            neg_risk_resp.neg_risk
138        };
139
140        // Fetch or use provided tick size
141        let tick_size = if let Some(tick_size) = options.and_then(|o| o.tick_size) {
142            tick_size
143        } else {
144            let tick_size_resp = self
145                .markets()
146                .tick_size(params.token_id.clone())
147                .send()
148                .await?;
149            let tick_size_val = tick_size_resp
150                .minimum_tick_size
151                .parse::<f64>()
152                .map_err(|e| {
153                    ClobError::validation(format!("Invalid minimum_tick_size field: {}", e))
154                })?;
155            TickSize::try_from(tick_size_val)?
156        };
157
158        // Get fee rate
159        let fee_rate_response: serde_json::Value = self
160            .client
161            .get(self.base_url.join("/fee-rate")?)
162            .send()
163            .await?
164            .json()
165            .await?;
166
167        let fee_rate_bps = fee_rate_response["feeRateBps"]
168            .as_str()
169            .unwrap_or("0")
170            .to_string();
171
172        // Calculate amounts
173        let (maker_amount, taker_amount) =
174            calculate_order_amounts(params.price, params.size, params.side, tick_size);
175
176        let signature_type = params.signature_type.unwrap_or_default();
177        let maker = if let Some(funder) = params.funder {
178            funder
179        } else if signature_type.is_proxy() {
180            // Fetch proxy from Gamma
181            let profile = self
182                .gamma
183                .user()
184                .get(account.address().to_string())
185                .send()
186                .await
187                .map_err(|e| ClobError::service(format!("Failed to fetch user profile: {}", e)))?;
188
189            profile
190                .proxy
191                .ok_or_else(|| {
192                    ClobError::validation(format!(
193                        "Signature type {:?} requires proxy, but none found for {}",
194                        signature_type,
195                        account.address()
196                    ))
197                })?
198                .parse::<Address>()
199                .map_err(|e| {
200                    ClobError::validation(format!("Invalid proxy address format from Gamma: {}", e))
201                })?
202        } else {
203            account.address()
204        };
205
206        Ok(Order {
207            salt: generate_salt(),
208            maker,
209            signer: account.address(),
210            taker: alloy::primitives::Address::ZERO,
211            token_id: params.token_id.clone(),
212            maker_amount,
213            taker_amount,
214            expiration: params.expiration.unwrap_or(0).to_string(),
215            nonce: "0".to_string(),
216            fee_rate_bps,
217            side: params.side,
218            signature_type,
219            neg_risk,
220        })
221    }
222
223    /// Create an unsigned market order from parameters
224    pub async fn create_market_order(
225        &self,
226        params: &MarketOrderArgs,
227        options: Option<PartialCreateOrderOptions>,
228    ) -> Result<Order, ClobError> {
229        let account = self
230            .account
231            .as_ref()
232            .ok_or_else(|| ClobError::validation("Account required to create order"))?;
233
234        if params.amount <= 0.0 {
235            return Err(ClobError::validation(format!(
236                "Amount must be positive, got {}",
237                params.amount
238            )));
239        }
240
241        // Fetch or use provided neg_risk status
242        let neg_risk = if let Some(neg_risk) = options.and_then(|o| o.neg_risk) {
243            neg_risk
244        } else {
245            let neg_risk_resp = self
246                .markets()
247                .neg_risk(params.token_id.clone())
248                .send()
249                .await?;
250            neg_risk_resp.neg_risk
251        };
252
253        // Fetch or use provided tick size
254        let tick_size = if let Some(tick_size) = options.and_then(|o| o.tick_size) {
255            tick_size
256        } else {
257            let tick_size_resp = self
258                .markets()
259                .tick_size(params.token_id.clone())
260                .send()
261                .await?;
262            let tick_size_val = tick_size_resp
263                .minimum_tick_size
264                .parse::<f64>()
265                .map_err(|e| {
266                    ClobError::validation(format!("Invalid minimum_tick_size field: {}", e))
267                })?;
268            TickSize::try_from(tick_size_val)?
269        };
270
271        // Determine price
272        let price = if let Some(p) = params.price {
273            p
274        } else {
275            // Fetch orderbook and calculate price
276            let book = self
277                .markets()
278                .order_book(params.token_id.clone())
279                .send()
280                .await?;
281
282            let levels = match params.side {
283                OrderSide::Buy => book.asks,
284                OrderSide::Sell => book.bids,
285            };
286
287            calculate_market_price(&levels, params.amount, params.side)
288                .ok_or_else(|| ClobError::validation("Not enough liquidity to fill market order"))?
289        };
290
291        // Get fee rate
292        let fee_rate_response: serde_json::Value = self
293            .client
294            .get(self.base_url.join("/fee-rate")?)
295            .send()
296            .await?
297            .json()
298            .await?;
299
300        let fee_rate_bps = fee_rate_response["feeRateBps"]
301            .as_str()
302            .unwrap_or("0")
303            .to_string();
304
305        // Calculate amounts
306        let (maker_amount, taker_amount) =
307            calculate_market_order_amounts(params.amount, price, params.side, tick_size);
308
309        let signature_type = params.signature_type.unwrap_or_default();
310        let maker = if let Some(funder) = params.funder {
311            funder
312        } else if signature_type.is_proxy() {
313            // Fetch proxy from Gamma
314            let profile = self
315                .gamma
316                .user()
317                .get(account.address().to_string())
318                .send()
319                .await
320                .map_err(|e| ClobError::service(format!("Failed to fetch user profile: {}", e)))?;
321
322            profile
323                .proxy
324                .ok_or_else(|| {
325                    ClobError::validation(format!(
326                        "Signature type {:?} requires proxy, but none found for {}",
327                        signature_type,
328                        account.address()
329                    ))
330                })?
331                .parse::<Address>()
332                .map_err(|e| {
333                    ClobError::validation(format!("Invalid proxy address format from Gamma: {}", e))
334                })?
335        } else {
336            account.address()
337        };
338
339        Ok(Order {
340            salt: generate_salt(),
341            maker,
342            signer: account.address(),
343            taker: alloy::primitives::Address::ZERO,
344            token_id: params.token_id.clone(),
345            maker_amount,
346            taker_amount,
347            // Market orders (FOK) usually technically expire immediately or 0?
348            // Python sets "0" expiration for market/FOK orders.
349            expiration: "0".to_string(),
350            nonce: "0".to_string(),
351            fee_rate_bps,
352            side: params.side,
353            signature_type,
354            neg_risk,
355        })
356    }
357    pub async fn sign_order(&self, order: &Order) -> Result<SignedOrder, ClobError> {
358        let account = self
359            .account
360            .as_ref()
361            .ok_or_else(|| ClobError::validation("Account required to sign order"))?;
362        account.sign_order(order, self.chain_id).await
363    }
364
365    /// Post a signed order
366    /// Post a signed order
367    pub async fn post_order(
368        &self,
369        signed_order: &SignedOrder,
370        order_type: OrderKind,
371        post_only: bool,
372    ) -> Result<OrderResponse, ClobError> {
373        let account = self
374            .account
375            .as_ref()
376            .ok_or_else(|| ClobError::validation("Account required to post order"))?;
377
378        let auth = AuthMode::L2 {
379            address: account.address(),
380            credentials: account.credentials().clone(),
381            signer: account.signer().clone(),
382        };
383
384        // Create the payload wrapping the signed order
385        let payload = serde_json::json!({
386            "order": signed_order,
387            "owner": account.credentials().key,
388            "orderType": order_type,
389            "postOnly": post_only,
390        });
391
392        Request::post(
393            self.client.clone(),
394            self.base_url.clone(),
395            "/order".to_string(),
396            auth,
397            self.chain_id,
398        )
399        .body(&payload)?
400        .send()
401        .await
402    }
403
404    /// Create, sign, and post an order (convenience method)
405    pub async fn place_order(
406        &self,
407        params: &CreateOrderParams,
408        options: Option<PartialCreateOrderOptions>,
409    ) -> Result<OrderResponse, ClobError> {
410        let order = self.create_order(params, options).await?;
411        let signed_order = self.sign_order(&order).await?;
412        self.post_order(&signed_order, params.order_type, params.post_only)
413            .await
414    }
415
416    /// Create, sign, and post a market order (convenience method)
417    pub async fn place_market_order(
418        &self,
419        params: &MarketOrderArgs,
420        options: Option<PartialCreateOrderOptions>,
421    ) -> Result<OrderResponse, ClobError> {
422        let order = self.create_market_order(params, options).await?;
423        let signed_order = self.sign_order(&order).await?;
424
425        let order_type = params.order_type.unwrap_or(OrderKind::Fok);
426        // Market orders are usually FOK
427
428        self.post_order(&signed_order, order_type, false) // Market orders cannot be post_only
429            .await
430    }
431}
432
433/// Parameters for creating an order
434#[derive(Debug, Clone)]
435pub struct CreateOrderParams {
436    pub token_id: String,
437    pub price: f64,
438    pub size: f64,
439    pub side: OrderSide,
440    pub order_type: OrderKind,
441    pub post_only: bool,
442    pub expiration: Option<u64>,
443    pub funder: Option<Address>,
444    pub signature_type: Option<SignatureType>,
445}
446
447impl CreateOrderParams {
448    pub fn validate(&self) -> Result<(), ClobError> {
449        if self.price <= 0.0 || self.price > 1.0 {
450            return Err(ClobError::validation(format!(
451                "Price must be between 0.0 and 1.0, got {}",
452                self.price
453            )));
454        }
455        if self.size <= 0.0 {
456            return Err(ClobError::validation(format!(
457                "Size must be positive, got {}",
458                self.size
459            )));
460        }
461        if self.price.is_nan() || self.size.is_nan() {
462            return Err(ClobError::validation("NaN values not allowed"));
463        }
464        Ok(())
465    }
466}
467
468/// Builder for CLOB client
469pub struct ClobBuilder {
470    base_url: String,
471    timeout_ms: u64,
472    pool_size: usize,
473    chain: Chain,
474    account: Option<Account>,
475    gamma: Option<Gamma>,
476}
477
478impl ClobBuilder {
479    /// Create a new builder with default configuration
480    pub fn new() -> Self {
481        Self {
482            base_url: DEFAULT_BASE_URL.to_string(),
483            timeout_ms: DEFAULT_TIMEOUT_MS,
484            pool_size: DEFAULT_POOL_SIZE,
485            chain: Chain::PolygonMainnet,
486            account: None,
487            gamma: None,
488        }
489    }
490
491    /// Set account for the client
492    pub fn with_account(mut self, account: Account) -> Self {
493        self.account = Some(account);
494        self
495    }
496
497    /// Set base URL for the API
498    pub fn base_url(mut self, url: impl Into<String>) -> Self {
499        self.base_url = url.into();
500        self
501    }
502
503    /// Set request timeout in milliseconds
504    pub fn timeout_ms(mut self, timeout: u64) -> Self {
505        self.timeout_ms = timeout;
506        self
507    }
508
509    /// Set connection pool size
510    pub fn pool_size(mut self, size: usize) -> Self {
511        self.pool_size = size;
512        self
513    }
514
515    /// Set chain
516    pub fn chain(mut self, chain: Chain) -> Self {
517        self.chain = chain;
518        self
519    }
520
521    /// Set Gamma client
522    pub fn gamma(mut self, gamma: Gamma) -> Self {
523        self.gamma = Some(gamma);
524        self
525    }
526
527    /// Build the CLOB client
528    pub fn build(self) -> Result<Clob, ClobError> {
529        let HttpClient { client, base_url } = HttpClientBuilder::new(&self.base_url)
530            .timeout_ms(self.timeout_ms)
531            .pool_size(self.pool_size)
532            .build()?;
533
534        let gamma = if let Some(gamma) = self.gamma {
535            gamma
536        } else {
537            polyoxide_gamma::Gamma::builder()
538                .timeout_ms(self.timeout_ms)
539                .pool_size(self.pool_size)
540                .build()
541                .map_err(|e| {
542                    ClobError::service(format!("Failed to build default Gamma client: {}", e))
543                })?
544        };
545
546        Ok(Clob {
547            client,
548            base_url,
549            chain_id: self.chain.chain_id(),
550            account: self.account,
551            gamma,
552        })
553    }
554}
555
556impl Default for ClobBuilder {
557    fn default() -> Self {
558        Self::new()
559    }
560}