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 market metadata (neg_risk and tick_size)
129        let (neg_risk, tick_size) = self.get_market_metadata(&params.token_id, options).await?;
130
131        // Get fee rate
132        let fee_rate_bps = self.get_fee_rate().await?;
133
134        // Calculate amounts
135        let (maker_amount, taker_amount) =
136            calculate_order_amounts(params.price, params.size, params.side, tick_size);
137
138        // Resolve maker address
139        let signature_type = params.signature_type.unwrap_or_default();
140        let maker = self
141            .resolve_maker_address(params.funder, signature_type, account)
142            .await?;
143
144        // Build order
145        Ok(Self::build_order(
146            params.token_id.clone(),
147            maker,
148            account.address(),
149            maker_amount,
150            taker_amount,
151            fee_rate_bps,
152            params.side,
153            signature_type,
154            neg_risk,
155            params.expiration,
156        ))
157    }
158
159    /// Create an unsigned market order from parameters
160    pub async fn create_market_order(
161        &self,
162        params: &MarketOrderArgs,
163        options: Option<PartialCreateOrderOptions>,
164    ) -> Result<Order, ClobError> {
165        let account = self
166            .account
167            .as_ref()
168            .ok_or_else(|| ClobError::validation("Account required to create order"))?;
169
170        if params.amount <= 0.0 {
171            return Err(ClobError::validation(format!(
172                "Amount must be positive, got {}",
173                params.amount
174            )));
175        }
176
177        // Fetch market metadata (neg_risk and tick_size)
178        let (neg_risk, tick_size) = self.get_market_metadata(&params.token_id, options).await?;
179
180        // Determine price
181        let price = if let Some(p) = params.price {
182            p
183        } else {
184            // Fetch orderbook and calculate price
185            let book = self
186                .markets()
187                .order_book(params.token_id.clone())
188                .send()
189                .await?;
190
191            let levels = match params.side {
192                OrderSide::Buy => book.asks,
193                OrderSide::Sell => book.bids,
194            };
195
196            calculate_market_price(&levels, params.amount, params.side)
197                .ok_or_else(|| ClobError::validation("Not enough liquidity to fill market order"))?
198        };
199
200        // Get fee rate
201        let fee_rate_bps = self.get_fee_rate().await?;
202
203        // Calculate amounts
204        let (maker_amount, taker_amount) =
205            calculate_market_order_amounts(params.amount, price, params.side, tick_size);
206
207        // Resolve maker address
208        let signature_type = params.signature_type.unwrap_or_default();
209        let maker = self
210            .resolve_maker_address(params.funder, signature_type, account)
211            .await?;
212
213        // Build order with expiration set to 0 for market orders
214        Ok(Self::build_order(
215            params.token_id.clone(),
216            maker,
217            account.address(),
218            maker_amount,
219            taker_amount,
220            fee_rate_bps,
221            params.side,
222            signature_type,
223            neg_risk,
224            Some(0),
225        ))
226    }
227    pub async fn sign_order(&self, order: &Order) -> Result<SignedOrder, ClobError> {
228        let account = self
229            .account
230            .as_ref()
231            .ok_or_else(|| ClobError::validation("Account required to sign order"))?;
232        account.sign_order(order, self.chain_id).await
233    }
234
235    // Helper methods for order creation
236
237    /// Fetch market metadata (neg_risk and tick_size) for a token
238    async fn get_market_metadata(
239        &self,
240        token_id: &str,
241        options: Option<PartialCreateOrderOptions>,
242    ) -> Result<(bool, TickSize), ClobError> {
243        // Fetch or use provided neg_risk status
244        let neg_risk = if let Some(neg_risk) = options.and_then(|o| o.neg_risk) {
245            neg_risk
246        } else {
247            let neg_risk_resp = self.markets().neg_risk(token_id.to_string()).send().await?;
248            neg_risk_resp.neg_risk
249        };
250
251        // Fetch or use provided tick size
252        let tick_size = if let Some(tick_size) = options.and_then(|o| o.tick_size) {
253            tick_size
254        } else {
255            let tick_size_resp = self
256                .markets()
257                .tick_size(token_id.to_string())
258                .send()
259                .await?;
260            let tick_size_val = tick_size_resp
261                .minimum_tick_size
262                .parse::<f64>()
263                .map_err(|e| {
264                    ClobError::validation(format!("Invalid minimum_tick_size field: {}", e))
265                })?;
266            TickSize::try_from(tick_size_val)?
267        };
268
269        Ok((neg_risk, tick_size))
270    }
271
272    /// Fetch the current fee rate from the API
273    async fn get_fee_rate(&self) -> Result<String, ClobError> {
274        let fee_rate_response: serde_json::Value = self
275            .client
276            .get(self.base_url.join("/fee-rate")?)
277            .send()
278            .await?
279            .json()
280            .await?;
281
282        Ok(fee_rate_response["feeRateBps"]
283            .as_str()
284            .unwrap_or("0")
285            .to_string())
286    }
287
288    /// Resolve the maker address based on funder and signature type
289    async fn resolve_maker_address(
290        &self,
291        funder: Option<Address>,
292        signature_type: SignatureType,
293        account: &Account,
294    ) -> Result<Address, ClobError> {
295        if let Some(funder) = funder {
296            Ok(funder)
297        } else if signature_type.is_proxy() {
298            // Fetch proxy from Gamma
299            let profile = self
300                .gamma
301                .user()
302                .get(account.address().to_string())
303                .send()
304                .await
305                .map_err(|e| ClobError::service(format!("Failed to fetch user profile: {}", e)))?;
306
307            profile
308                .proxy
309                .ok_or_else(|| {
310                    ClobError::validation(format!(
311                        "Signature type {:?} requires proxy, but none found for {}",
312                        signature_type,
313                        account.address()
314                    ))
315                })?
316                .parse::<Address>()
317                .map_err(|e| {
318                    ClobError::validation(format!("Invalid proxy address format from Gamma: {}", e))
319                })
320        } else {
321            Ok(account.address())
322        }
323    }
324
325    /// Build an Order struct from the provided parameters
326    #[allow(clippy::too_many_arguments)]
327    fn build_order(
328        token_id: String,
329        maker: Address,
330        signer: Address,
331        maker_amount: String,
332        taker_amount: String,
333        fee_rate_bps: String,
334        side: OrderSide,
335        signature_type: SignatureType,
336        neg_risk: bool,
337        expiration: Option<u64>,
338    ) -> Order {
339        Order {
340            salt: generate_salt(),
341            maker,
342            signer,
343            taker: alloy::primitives::Address::ZERO,
344            token_id,
345            maker_amount,
346            taker_amount,
347            expiration: expiration.unwrap_or(0).to_string(),
348            nonce: "0".to_string(),
349            fee_rate_bps,
350            side,
351            signature_type,
352            neg_risk,
353        }
354    }
355
356    /// Post a signed order
357    /// Post a signed order
358    pub async fn post_order(
359        &self,
360        signed_order: &SignedOrder,
361        order_type: OrderKind,
362        post_only: bool,
363    ) -> Result<OrderResponse, ClobError> {
364        let account = self
365            .account
366            .as_ref()
367            .ok_or_else(|| ClobError::validation("Account required to post order"))?;
368
369        let auth = AuthMode::L2 {
370            address: account.address(),
371            credentials: account.credentials().clone(),
372            signer: account.signer().clone(),
373        };
374
375        // Create the payload wrapping the signed order
376        let payload = serde_json::json!({
377            "order": signed_order,
378            "owner": account.credentials().key,
379            "orderType": order_type,
380            "postOnly": post_only,
381        });
382
383        Request::post(
384            self.client.clone(),
385            self.base_url.clone(),
386            "/order".to_string(),
387            auth,
388            self.chain_id,
389        )
390        .body(&payload)?
391        .send()
392        .await
393    }
394
395    /// Create, sign, and post an order (convenience method)
396    pub async fn place_order(
397        &self,
398        params: &CreateOrderParams,
399        options: Option<PartialCreateOrderOptions>,
400    ) -> Result<OrderResponse, ClobError> {
401        let order = self.create_order(params, options).await?;
402        let signed_order = self.sign_order(&order).await?;
403        self.post_order(&signed_order, params.order_type, params.post_only)
404            .await
405    }
406
407    /// Create, sign, and post a market order (convenience method)
408    pub async fn place_market_order(
409        &self,
410        params: &MarketOrderArgs,
411        options: Option<PartialCreateOrderOptions>,
412    ) -> Result<OrderResponse, ClobError> {
413        let order = self.create_market_order(params, options).await?;
414        let signed_order = self.sign_order(&order).await?;
415
416        let order_type = params.order_type.unwrap_or(OrderKind::Fok);
417        // Market orders are usually FOK
418
419        self.post_order(&signed_order, order_type, false) // Market orders cannot be post_only
420            .await
421    }
422}
423
424/// Parameters for creating an order
425#[derive(Debug, Clone)]
426pub struct CreateOrderParams {
427    pub token_id: String,
428    pub price: f64,
429    pub size: f64,
430    pub side: OrderSide,
431    pub order_type: OrderKind,
432    pub post_only: bool,
433    pub expiration: Option<u64>,
434    pub funder: Option<Address>,
435    pub signature_type: Option<SignatureType>,
436}
437
438impl CreateOrderParams {
439    pub fn validate(&self) -> Result<(), ClobError> {
440        if self.price <= 0.0 || self.price > 1.0 {
441            return Err(ClobError::validation(format!(
442                "Price must be between 0.0 and 1.0, got {}",
443                self.price
444            )));
445        }
446        if self.size <= 0.0 {
447            return Err(ClobError::validation(format!(
448                "Size must be positive, got {}",
449                self.size
450            )));
451        }
452        if self.price.is_nan() || self.size.is_nan() {
453            return Err(ClobError::validation("NaN values not allowed"));
454        }
455        Ok(())
456    }
457}
458
459/// Builder for CLOB client
460pub struct ClobBuilder {
461    base_url: String,
462    timeout_ms: u64,
463    pool_size: usize,
464    chain: Chain,
465    account: Option<Account>,
466    gamma: Option<Gamma>,
467}
468
469impl ClobBuilder {
470    /// Create a new builder with default configuration
471    pub fn new() -> Self {
472        Self {
473            base_url: DEFAULT_BASE_URL.to_string(),
474            timeout_ms: DEFAULT_TIMEOUT_MS,
475            pool_size: DEFAULT_POOL_SIZE,
476            chain: Chain::PolygonMainnet,
477            account: None,
478            gamma: None,
479        }
480    }
481
482    /// Set account for the client
483    pub fn with_account(mut self, account: Account) -> Self {
484        self.account = Some(account);
485        self
486    }
487
488    /// Set base URL for the API
489    pub fn base_url(mut self, url: impl Into<String>) -> Self {
490        self.base_url = url.into();
491        self
492    }
493
494    /// Set request timeout in milliseconds
495    pub fn timeout_ms(mut self, timeout: u64) -> Self {
496        self.timeout_ms = timeout;
497        self
498    }
499
500    /// Set connection pool size
501    pub fn pool_size(mut self, size: usize) -> Self {
502        self.pool_size = size;
503        self
504    }
505
506    /// Set chain
507    pub fn chain(mut self, chain: Chain) -> Self {
508        self.chain = chain;
509        self
510    }
511
512    /// Set Gamma client
513    pub fn gamma(mut self, gamma: Gamma) -> Self {
514        self.gamma = Some(gamma);
515        self
516    }
517
518    /// Build the CLOB client
519    pub fn build(self) -> Result<Clob, ClobError> {
520        let HttpClient { client, base_url } = HttpClientBuilder::new(&self.base_url)
521            .timeout_ms(self.timeout_ms)
522            .pool_size(self.pool_size)
523            .build()?;
524
525        let gamma = if let Some(gamma) = self.gamma {
526            gamma
527        } else {
528            polyoxide_gamma::Gamma::builder()
529                .timeout_ms(self.timeout_ms)
530                .pool_size(self.pool_size)
531                .build()
532                .map_err(|e| {
533                    ClobError::service(format!("Failed to build default Gamma client: {}", e))
534                })?
535        };
536
537        Ok(Clob {
538            client,
539            base_url,
540            chain_id: self.chain.chain_id(),
541            account: self.account,
542            gamma,
543        })
544    }
545}
546
547impl Default for ClobBuilder {
548    fn default() -> Self {
549        Self::new()
550    }
551}