Skip to main content

polyoxide_clob/
client.rs

1use polyoxide_core::{
2    HttpClient, HttpClientBuilder, RateLimiter, RetryConfig, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS,
3};
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) http_client: HttpClient,
25    pub(crate) chain_id: u64,
26    pub(crate) account: Option<Account>,
27    pub(crate) gamma: Gamma,
28}
29
30impl Clob {
31    /// Create a new CLOB client with default configuration
32    pub fn new(
33        private_key: impl Into<String>,
34        credentials: Credentials,
35    ) -> Result<Self, ClobError> {
36        Self::builder(private_key, credentials)?.build()
37    }
38
39    /// Create a new public CLOB client (read-only)
40    pub fn public() -> Self {
41        ClobBuilder::new().build().unwrap() // unwrap safe because default build never fails
42    }
43
44    /// Create a new CLOB client builder with required authentication
45    pub fn builder(
46        private_key: impl Into<String>,
47        credentials: Credentials,
48    ) -> Result<ClobBuilder, ClobError> {
49        let account = Account::new(private_key, credentials)?;
50        Ok(ClobBuilder::new().with_account(account))
51    }
52
53    /// Create a new CLOB client from an Account
54    pub fn from_account(account: Account) -> Result<Self, ClobError> {
55        ClobBuilder::new().with_account(account).build()
56    }
57
58    /// Get a reference to the account
59    pub fn account(&self) -> Option<&Account> {
60        self.account.as_ref()
61    }
62
63    /// Get markets namespace
64    pub fn markets(&self) -> Markets {
65        Markets {
66            http_client: self.http_client.clone(),
67            chain_id: self.chain_id,
68        }
69    }
70
71    /// Get health namespace for latency and health checks
72    pub fn health(&self) -> Health {
73        Health {
74            http_client: self.http_client.clone(),
75        }
76    }
77
78    /// Get orders namespace
79    pub fn orders(&self) -> Result<Orders, ClobError> {
80        let account = self
81            .account
82            .as_ref()
83            .ok_or_else(|| ClobError::validation("Account required for orders API"))?;
84
85        Ok(Orders {
86            http_client: self.http_client.clone(),
87            wallet: account.wallet().clone(),
88            credentials: account.credentials().clone(),
89            signer: account.signer().clone(),
90            chain_id: self.chain_id,
91        })
92    }
93
94    /// Get account API namespace
95    pub fn account_api(&self) -> Result<AccountApi, ClobError> {
96        let account = self
97            .account
98            .as_ref()
99            .ok_or_else(|| ClobError::validation("Account required for account API"))?;
100
101        Ok(AccountApi {
102            http_client: self.http_client.clone(),
103            wallet: account.wallet().clone(),
104            credentials: account.credentials().clone(),
105            signer: account.signer().clone(),
106            chain_id: self.chain_id,
107        })
108    }
109
110    /// Create an unsigned order from parameters
111    pub async fn create_order(
112        &self,
113        params: &CreateOrderParams,
114        options: Option<PartialCreateOrderOptions>,
115    ) -> Result<Order, ClobError> {
116        let account = self
117            .account
118            .as_ref()
119            .ok_or_else(|| ClobError::validation("Account required to create order"))?;
120
121        params.validate()?;
122
123        // Fetch market metadata (neg_risk and tick_size)
124        let (neg_risk, tick_size) = self.get_market_metadata(&params.token_id, options).await?;
125
126        // Get fee rate
127        let fee_rate_bps = self.get_fee_rate(&params.token_id).await?;
128
129        // Calculate amounts
130        let (maker_amount, taker_amount) =
131            calculate_order_amounts(params.price, params.size, params.side, tick_size);
132
133        // Resolve maker address
134        let signature_type = params.signature_type.unwrap_or_default();
135        let maker = self
136            .resolve_maker_address(params.funder, signature_type, account)
137            .await?;
138
139        // Build order
140        Ok(Self::build_order(
141            params.token_id.clone(),
142            maker,
143            account.address(),
144            maker_amount,
145            taker_amount,
146            fee_rate_bps,
147            params.side,
148            signature_type,
149            neg_risk,
150            params.expiration,
151        ))
152    }
153
154    /// Create an unsigned market order from parameters
155    pub async fn create_market_order(
156        &self,
157        params: &MarketOrderArgs,
158        options: Option<PartialCreateOrderOptions>,
159    ) -> Result<Order, ClobError> {
160        let account = self
161            .account
162            .as_ref()
163            .ok_or_else(|| ClobError::validation("Account required to create order"))?;
164
165        if !params.amount.is_finite() {
166            return Err(ClobError::validation(
167                "Amount must be finite (no NaN or infinity)",
168            ));
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        if let Some(p) = params.price {
177            if !p.is_finite() || p <= 0.0 || p > 1.0 {
178                return Err(ClobError::validation(format!(
179                    "Price must be finite and between 0.0 and 1.0, got {}",
180                    p
181                )));
182            }
183        }
184
185        // Fetch market metadata (neg_risk and tick_size)
186        let (neg_risk, tick_size) = self.get_market_metadata(&params.token_id, options).await?;
187
188        // Determine price
189        let price = if let Some(p) = params.price {
190            p
191        } else {
192            // Fetch orderbook and calculate price
193            let book = self
194                .markets()
195                .order_book(params.token_id.clone())
196                .send()
197                .await?;
198
199            let levels = match params.side {
200                OrderSide::Buy => book.asks,
201                OrderSide::Sell => book.bids,
202            };
203
204            calculate_market_price(&levels, params.amount, params.side)
205                .ok_or_else(|| ClobError::validation("Not enough liquidity to fill market order"))?
206        };
207
208        // Get fee rate
209        let fee_rate_bps = self.get_fee_rate(&params.token_id).await?;
210
211        // Calculate amounts
212        let (maker_amount, taker_amount) =
213            calculate_market_order_amounts(params.amount, price, params.side, tick_size);
214
215        // Resolve maker address
216        let signature_type = params.signature_type.unwrap_or_default();
217        let maker = self
218            .resolve_maker_address(params.funder, signature_type, account)
219            .await?;
220
221        // Build order with expiration set to 0 for market orders
222        Ok(Self::build_order(
223            params.token_id.clone(),
224            maker,
225            account.address(),
226            maker_amount,
227            taker_amount,
228            fee_rate_bps,
229            params.side,
230            signature_type,
231            neg_risk,
232            Some(0),
233        ))
234    }
235    pub async fn sign_order(&self, order: &Order) -> Result<SignedOrder, ClobError> {
236        let account = self
237            .account
238            .as_ref()
239            .ok_or_else(|| ClobError::validation("Account required to sign order"))?;
240        account.sign_order(order, self.chain_id).await
241    }
242
243    // Helper methods for order creation
244
245    /// Fetch market metadata (neg_risk and tick_size) for a token
246    async fn get_market_metadata(
247        &self,
248        token_id: &str,
249        options: Option<PartialCreateOrderOptions>,
250    ) -> Result<(bool, TickSize), ClobError> {
251        // Fetch or use provided neg_risk status
252        let neg_risk = if let Some(neg_risk) = options.and_then(|o| o.neg_risk) {
253            neg_risk
254        } else {
255            let neg_risk_resp = self.markets().neg_risk(token_id.to_string()).send().await?;
256            neg_risk_resp.neg_risk
257        };
258
259        // Fetch or use provided tick size
260        let tick_size = if let Some(tick_size) = options.and_then(|o| o.tick_size) {
261            tick_size
262        } else {
263            let tick_size_resp = self
264                .markets()
265                .tick_size(token_id.to_string())
266                .send()
267                .await?;
268            let tick_size_val = tick_size_resp
269                .minimum_tick_size
270                .parse::<f64>()
271                .map_err(|e| {
272                    ClobError::validation(format!("Invalid minimum_tick_size field: {}", e))
273                })?;
274            TickSize::try_from(tick_size_val)?
275        };
276
277        Ok((neg_risk, tick_size))
278    }
279
280    /// Fetch the current fee rate for a token from the API
281    async fn get_fee_rate(&self, token_id: &str) -> Result<String, ClobError> {
282        let resp = self.markets().fee_rate(token_id).send().await?;
283        Ok(resp.base_fee.to_string())
284    }
285
286    /// Resolve the maker address based on funder and signature type
287    async fn resolve_maker_address(
288        &self,
289        funder: Option<Address>,
290        signature_type: SignatureType,
291        account: &Account,
292    ) -> Result<Address, ClobError> {
293        if let Some(funder) = funder {
294            Ok(funder)
295        } else if signature_type.is_proxy() {
296            // Fetch proxy from Gamma
297            let profile = self
298                .gamma
299                .user()
300                .get(account.address().to_string())
301                .send()
302                .await
303                .map_err(|e| ClobError::service(format!("Failed to fetch user profile: {}", e)))?;
304
305            profile
306                .proxy
307                .ok_or_else(|| {
308                    ClobError::validation(format!(
309                        "Signature type {:?} requires proxy, but none found for {}",
310                        signature_type,
311                        account.address()
312                    ))
313                })?
314                .parse::<Address>()
315                .map_err(|e| {
316                    ClobError::validation(format!("Invalid proxy address format from Gamma: {}", e))
317                })
318        } else {
319            Ok(account.address())
320        }
321    }
322
323    /// Build an Order struct from the provided parameters
324    #[allow(clippy::too_many_arguments)]
325    fn build_order(
326        token_id: String,
327        maker: Address,
328        signer: Address,
329        maker_amount: String,
330        taker_amount: String,
331        fee_rate_bps: String,
332        side: OrderSide,
333        signature_type: SignatureType,
334        neg_risk: bool,
335        expiration: Option<u64>,
336    ) -> Order {
337        Order {
338            salt: generate_salt(),
339            maker,
340            signer,
341            taker: alloy::primitives::Address::ZERO,
342            token_id,
343            maker_amount,
344            taker_amount,
345            expiration: expiration.unwrap_or(0).to_string(),
346            nonce: "0".to_string(),
347            fee_rate_bps,
348            side,
349            signature_type,
350            neg_risk,
351        }
352    }
353
354    /// Post a signed order
355    pub async fn post_order(
356        &self,
357        signed_order: &SignedOrder,
358        order_type: OrderKind,
359        post_only: bool,
360    ) -> Result<OrderResponse, ClobError> {
361        let account = self
362            .account
363            .as_ref()
364            .ok_or_else(|| ClobError::validation("Account required to post order"))?;
365
366        let auth = AuthMode::L2 {
367            address: account.address(),
368            credentials: account.credentials().clone(),
369            signer: account.signer().clone(),
370        };
371
372        // Create the payload wrapping the signed order
373        let payload = serde_json::json!({
374            "order": signed_order,
375            "owner": account.credentials().key,
376            "orderType": order_type,
377            "postOnly": post_only,
378        });
379
380        Request::post(
381            self.http_client.clone(),
382            "/order".to_string(),
383            auth,
384            self.chain_id,
385        )
386        .body(&payload)?
387        .send()
388        .await
389    }
390
391    /// Create, sign, and post an order (convenience method)
392    pub async fn place_order(
393        &self,
394        params: &CreateOrderParams,
395        options: Option<PartialCreateOrderOptions>,
396    ) -> Result<OrderResponse, ClobError> {
397        let order = self.create_order(params, options).await?;
398        let signed_order = self.sign_order(&order).await?;
399        self.post_order(&signed_order, params.order_type, params.post_only)
400            .await
401    }
402
403    /// Create, sign, and post a market order (convenience method)
404    pub async fn place_market_order(
405        &self,
406        params: &MarketOrderArgs,
407        options: Option<PartialCreateOrderOptions>,
408    ) -> Result<OrderResponse, ClobError> {
409        let order = self.create_market_order(params, options).await?;
410        let signed_order = self.sign_order(&order).await?;
411
412        let order_type = params.order_type.unwrap_or(OrderKind::Fok);
413        // Market orders are usually FOK
414
415        self.post_order(&signed_order, order_type, false) // Market orders cannot be post_only
416            .await
417    }
418}
419
420/// Parameters for creating an order
421#[derive(Debug, Clone)]
422pub struct CreateOrderParams {
423    pub token_id: String,
424    pub price: f64,
425    pub size: f64,
426    pub side: OrderSide,
427    pub order_type: OrderKind,
428    pub post_only: bool,
429    pub expiration: Option<u64>,
430    pub funder: Option<Address>,
431    pub signature_type: Option<SignatureType>,
432}
433
434impl CreateOrderParams {
435    pub fn validate(&self) -> Result<(), ClobError> {
436        if !self.price.is_finite() || !self.size.is_finite() {
437            return Err(ClobError::validation(
438                "Price and size must be finite (no NaN or infinity)",
439            ));
440        }
441        if self.price <= 0.0 || self.price > 1.0 {
442            return Err(ClobError::validation(format!(
443                "Price must be between 0.0 and 1.0, got {}",
444                self.price
445            )));
446        }
447        if self.size <= 0.0 {
448            return Err(ClobError::validation(format!(
449                "Size must be positive, got {}",
450                self.size
451            )));
452        }
453        Ok(())
454    }
455}
456
457/// Builder for CLOB client
458pub struct ClobBuilder {
459    base_url: String,
460    timeout_ms: u64,
461    pool_size: usize,
462    chain: Chain,
463    account: Option<Account>,
464    gamma: Option<Gamma>,
465    retry_config: Option<RetryConfig>,
466}
467
468impl ClobBuilder {
469    /// Create a new builder with default configuration
470    pub fn new() -> Self {
471        Self {
472            base_url: DEFAULT_BASE_URL.to_string(),
473            timeout_ms: DEFAULT_TIMEOUT_MS,
474            pool_size: DEFAULT_POOL_SIZE,
475            chain: Chain::PolygonMainnet,
476            account: None,
477            gamma: None,
478            retry_config: 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    /// Set retry configuration for 429 responses
519    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
520        self.retry_config = Some(config);
521        self
522    }
523
524    /// Build the CLOB client
525    pub fn build(self) -> Result<Clob, ClobError> {
526        let mut builder = HttpClientBuilder::new(&self.base_url)
527            .timeout_ms(self.timeout_ms)
528            .pool_size(self.pool_size)
529            .with_rate_limiter(RateLimiter::clob_default());
530        if let Some(config) = self.retry_config {
531            builder = builder.with_retry_config(config);
532        }
533        let http_client = builder.build()?;
534
535        let gamma = if let Some(gamma) = self.gamma {
536            gamma
537        } else {
538            polyoxide_gamma::Gamma::builder()
539                .timeout_ms(self.timeout_ms)
540                .pool_size(self.pool_size)
541                .build()
542                .map_err(|e| {
543                    ClobError::service(format!("Failed to build default Gamma client: {}", e))
544                })?
545        };
546
547        Ok(Clob {
548            http_client,
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}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    #[test]
567    fn test_builder_custom_retry_config() {
568        let config = RetryConfig {
569            max_retries: 5,
570            initial_backoff_ms: 1000,
571            max_backoff_ms: 30_000,
572        };
573        let builder = ClobBuilder::new().with_retry_config(config);
574        let config = builder.retry_config.unwrap();
575        assert_eq!(config.max_retries, 5);
576        assert_eq!(config.initial_backoff_ms, 1000);
577    }
578
579    fn make_params(price: f64, size: f64) -> CreateOrderParams {
580        CreateOrderParams {
581            token_id: "test".to_string(),
582            price,
583            size,
584            side: OrderSide::Buy,
585            order_type: OrderKind::Gtc,
586            post_only: false,
587            expiration: None,
588            funder: None,
589            signature_type: None,
590        }
591    }
592
593    #[test]
594    fn test_validate_rejects_nan_price() {
595        let params = make_params(f64::NAN, 100.0);
596        let err = params.validate().unwrap_err();
597        assert!(err.to_string().contains("finite"));
598    }
599
600    #[test]
601    fn test_validate_rejects_nan_size() {
602        let params = make_params(0.5, f64::NAN);
603        let err = params.validate().unwrap_err();
604        assert!(err.to_string().contains("finite"));
605    }
606
607    #[test]
608    fn test_validate_rejects_infinite_price() {
609        let params = make_params(f64::INFINITY, 100.0);
610        let err = params.validate().unwrap_err();
611        assert!(err.to_string().contains("finite"));
612    }
613
614    #[test]
615    fn test_validate_rejects_infinite_size() {
616        let params = make_params(0.5, f64::INFINITY);
617        let err = params.validate().unwrap_err();
618        assert!(err.to_string().contains("finite"));
619    }
620
621    #[test]
622    fn test_validate_rejects_neg_infinity_size() {
623        let params = make_params(0.5, f64::NEG_INFINITY);
624        let err = params.validate().unwrap_err();
625        assert!(err.to_string().contains("finite"));
626    }
627
628    #[test]
629    fn test_validate_rejects_price_out_of_range() {
630        let params = make_params(1.5, 100.0);
631        let err = params.validate().unwrap_err();
632        assert!(err.to_string().contains("between 0.0 and 1.0"));
633    }
634
635    #[test]
636    fn test_validate_rejects_zero_price() {
637        let params = make_params(0.0, 100.0);
638        let err = params.validate().unwrap_err();
639        assert!(err.to_string().contains("between 0.0 and 1.0"));
640    }
641
642    #[test]
643    fn test_validate_rejects_negative_size() {
644        let params = make_params(0.5, -10.0);
645        let err = params.validate().unwrap_err();
646        assert!(err.to_string().contains("positive"));
647    }
648
649    #[test]
650    fn test_validate_accepts_valid_params() {
651        let params = make_params(0.5, 100.0);
652        assert!(params.validate().is_ok());
653    }
654
655    #[test]
656    fn test_validate_accepts_boundary_price() {
657        // Price exactly 1.0 should be valid
658        let params = make_params(1.0, 100.0);
659        assert!(params.validate().is_ok());
660    }
661}