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::{
8        account::AccountApi, auth::Auth, notifications::Notifications, orders::OrderResponse,
9        rewards::Rewards, rfq::Rfq, Health, Markets, Orders,
10    },
11    core::chain::Chain,
12    error::ClobError,
13    request::{AuthMode, Request},
14    types::*,
15    utils::{
16        calculate_market_order_amounts, calculate_market_price, calculate_order_amounts,
17        generate_salt,
18    },
19};
20use alloy::primitives::Address;
21#[cfg(feature = "gamma")]
22use polyoxide_gamma::Gamma;
23
24const DEFAULT_BASE_URL: &str = "https://clob.polymarket.com";
25
26/// CLOB (Central Limit Order Book) trading client for Polymarket.
27///
28/// Provides authenticated order creation, signing, and submission, plus read-only
29/// market data and order book access. Use [`Clob::public()`] for unauthenticated
30/// read-only access, or [`Clob::builder()`] for full trading capabilities.
31#[derive(Clone)]
32pub struct Clob {
33    pub(crate) http_client: HttpClient,
34    pub(crate) chain_id: u64,
35    pub(crate) account: Option<Account>,
36    #[cfg(feature = "gamma")]
37    pub(crate) gamma: Gamma,
38}
39
40impl Clob {
41    /// Create a new CLOB client with default configuration
42    pub fn new(
43        private_key: impl Into<String>,
44        credentials: Credentials,
45    ) -> Result<Self, ClobError> {
46        Self::builder(private_key, credentials)?.build()
47    }
48
49    /// Create a new public CLOB client (read-only)
50    pub fn public() -> Self {
51        ClobBuilder::new().build().unwrap() // unwrap safe because default build never fails
52    }
53
54    /// Create a new CLOB client builder with required authentication
55    pub fn builder(
56        private_key: impl Into<String>,
57        credentials: Credentials,
58    ) -> Result<ClobBuilder, ClobError> {
59        let account = Account::new(private_key, credentials)?;
60        Ok(ClobBuilder::new().with_account(account))
61    }
62
63    /// Create a new CLOB client from an Account
64    pub fn from_account(account: Account) -> Result<Self, ClobError> {
65        ClobBuilder::new().with_account(account).build()
66    }
67
68    /// Get a reference to the account
69    pub fn account(&self) -> Option<&Account> {
70        self.account.as_ref()
71    }
72
73    /// Get markets namespace
74    pub fn markets(&self) -> Markets {
75        Markets {
76            http_client: self.http_client.clone(),
77            chain_id: self.chain_id,
78        }
79    }
80
81    /// Get health namespace for latency and health checks
82    pub fn health(&self) -> Health {
83        Health {
84            http_client: self.http_client.clone(),
85            chain_id: self.chain_id,
86        }
87    }
88
89    /// Get orders namespace
90    pub fn orders(&self) -> Result<Orders, ClobError> {
91        let account = self
92            .account
93            .as_ref()
94            .ok_or_else(|| ClobError::validation("Account required for orders API"))?;
95
96        Ok(Orders {
97            http_client: self.http_client.clone(),
98            wallet: account.wallet().clone(),
99            credentials: account.credentials().clone(),
100            signer: account.signer().clone(),
101            chain_id: self.chain_id,
102        })
103    }
104
105    /// Get account API namespace
106    pub fn account_api(&self) -> Result<AccountApi, ClobError> {
107        let account = self
108            .account
109            .as_ref()
110            .ok_or_else(|| ClobError::validation("Account required for account API"))?;
111
112        Ok(AccountApi {
113            http_client: self.http_client.clone(),
114            wallet: account.wallet().clone(),
115            credentials: account.credentials().clone(),
116            signer: account.signer().clone(),
117            chain_id: self.chain_id,
118        })
119    }
120
121    /// Get notifications namespace
122    pub fn notifications(&self) -> Result<Notifications, ClobError> {
123        let account = self
124            .account
125            .as_ref()
126            .ok_or_else(|| ClobError::validation("Account required for notifications API"))?;
127
128        Ok(Notifications {
129            http_client: self.http_client.clone(),
130            wallet: account.wallet().clone(),
131            credentials: account.credentials().clone(),
132            signer: account.signer().clone(),
133            chain_id: self.chain_id,
134        })
135    }
136
137    /// Get RFQ namespace for request-for-quote operations
138    pub fn rfq(&self) -> Result<Rfq, ClobError> {
139        let account = self
140            .account
141            .as_ref()
142            .ok_or_else(|| ClobError::validation("Account required for RFQ API"))?;
143
144        Ok(Rfq {
145            http_client: self.http_client.clone(),
146            wallet: account.wallet().clone(),
147            credentials: account.credentials().clone(),
148            signer: account.signer().clone(),
149            chain_id: self.chain_id,
150        })
151    }
152
153    /// Get rewards namespace for liquidity reward operations
154    pub fn rewards(&self) -> Result<Rewards, ClobError> {
155        let account = self
156            .account
157            .as_ref()
158            .ok_or_else(|| ClobError::validation("Account required for rewards API"))?;
159
160        Ok(Rewards {
161            http_client: self.http_client.clone(),
162            wallet: account.wallet().clone(),
163            credentials: account.credentials().clone(),
164            signer: account.signer().clone(),
165            chain_id: self.chain_id,
166        })
167    }
168
169    /// Get auth namespace for API key management
170    pub fn auth(&self) -> Result<Auth, ClobError> {
171        let account = self
172            .account
173            .as_ref()
174            .ok_or_else(|| ClobError::validation("Account required for auth API"))?;
175
176        Ok(Auth {
177            http_client: self.http_client.clone(),
178            wallet: account.wallet().clone(),
179            credentials: account.credentials().clone(),
180            signer: account.signer().clone(),
181            chain_id: self.chain_id,
182        })
183    }
184
185    /// Create an unsigned order from parameters
186    pub async fn create_order(
187        &self,
188        params: &CreateOrderParams,
189        options: Option<PartialCreateOrderOptions>,
190    ) -> Result<Order, ClobError> {
191        let account = self
192            .account
193            .as_ref()
194            .ok_or_else(|| ClobError::validation("Account required to create order"))?;
195
196        params.validate()?;
197
198        // Fetch market metadata (neg_risk and tick_size)
199        let (neg_risk, tick_size) = self.get_market_metadata(&params.token_id, options).await?;
200
201        // Get fee rate
202        let fee_rate_bps = self.get_fee_rate(&params.token_id).await?;
203
204        // Calculate amounts
205        let (maker_amount, taker_amount) =
206            calculate_order_amounts(params.price, params.size, params.side, tick_size);
207
208        // Resolve maker address
209        let signature_type = params.signature_type.unwrap_or_default();
210        let maker = self
211            .resolve_maker_address(params.funder, signature_type, account)
212            .await?;
213
214        // Build order
215        Ok(Self::build_order(
216            params.token_id.clone(),
217            maker,
218            account.address(),
219            maker_amount,
220            taker_amount,
221            fee_rate_bps,
222            params.side,
223            signature_type,
224            neg_risk,
225            params.expiration,
226        ))
227    }
228
229    /// Create an unsigned market order from parameters
230    pub async fn create_market_order(
231        &self,
232        params: &MarketOrderArgs,
233        options: Option<PartialCreateOrderOptions>,
234    ) -> Result<Order, ClobError> {
235        let account = self
236            .account
237            .as_ref()
238            .ok_or_else(|| ClobError::validation("Account required to create order"))?;
239
240        if !params.amount.is_finite() {
241            return Err(ClobError::validation(
242                "Amount must be finite (no NaN or infinity)",
243            ));
244        }
245        if params.amount <= 0.0 {
246            return Err(ClobError::validation(format!(
247                "Amount must be positive, got {}",
248                params.amount
249            )));
250        }
251        if let Some(p) = params.price {
252            if !p.is_finite() || p <= 0.0 || p > 1.0 {
253                return Err(ClobError::validation(format!(
254                    "Price must be finite and between 0.0 and 1.0, got {}",
255                    p
256                )));
257            }
258        }
259
260        // Fetch market metadata (neg_risk and tick_size)
261        let (neg_risk, tick_size) = self.get_market_metadata(&params.token_id, options).await?;
262
263        // Determine price
264        let price = if let Some(p) = params.price {
265            p
266        } else {
267            // Fetch orderbook and calculate price
268            let book = self
269                .markets()
270                .order_book(params.token_id.clone())
271                .send()
272                .await?;
273
274            let levels = match params.side {
275                OrderSide::Buy => book.asks,
276                OrderSide::Sell => book.bids,
277            };
278
279            calculate_market_price(&levels, params.amount, params.side)
280                .ok_or_else(|| ClobError::validation("Not enough liquidity to fill market order"))?
281        };
282
283        // Get fee rate
284        let fee_rate_bps = self.get_fee_rate(&params.token_id).await?;
285
286        // Calculate amounts
287        let (maker_amount, taker_amount) =
288            calculate_market_order_amounts(params.amount, price, params.side, tick_size);
289
290        // Resolve maker address
291        let signature_type = params.signature_type.unwrap_or_default();
292        let maker = self
293            .resolve_maker_address(params.funder, signature_type, account)
294            .await?;
295
296        // Build order with expiration set to 0 for market orders
297        Ok(Self::build_order(
298            params.token_id.clone(),
299            maker,
300            account.address(),
301            maker_amount,
302            taker_amount,
303            fee_rate_bps,
304            params.side,
305            signature_type,
306            neg_risk,
307            Some(0),
308        ))
309    }
310    /// Sign an order using the configured account's EIP-712 signer.
311    pub async fn sign_order(&self, order: &Order) -> Result<SignedOrder, ClobError> {
312        let account = self
313            .account
314            .as_ref()
315            .ok_or_else(|| ClobError::validation("Account required to sign order"))?;
316        account.sign_order(order, self.chain_id).await
317    }
318
319    // Helper methods for order creation
320
321    /// Fetch market metadata (neg_risk and tick_size) for a token
322    async fn get_market_metadata(
323        &self,
324        token_id: &str,
325        options: Option<PartialCreateOrderOptions>,
326    ) -> Result<(bool, TickSize), ClobError> {
327        // Fetch or use provided neg_risk status
328        let neg_risk = if let Some(neg_risk) = options.and_then(|o| o.neg_risk) {
329            neg_risk
330        } else {
331            let neg_risk_resp = self.markets().neg_risk(token_id.to_string()).send().await?;
332            neg_risk_resp.neg_risk
333        };
334
335        // Fetch or use provided tick size
336        let tick_size = if let Some(tick_size) = options.and_then(|o| o.tick_size) {
337            tick_size
338        } else {
339            let tick_size_resp = self
340                .markets()
341                .tick_size(token_id.to_string())
342                .send()
343                .await?;
344            let tick_size_val = tick_size_resp
345                .minimum_tick_size
346                .parse::<f64>()
347                .map_err(|e| {
348                    ClobError::validation(format!("Invalid minimum_tick_size field: {}", e))
349                })?;
350            TickSize::try_from(tick_size_val)?
351        };
352
353        Ok((neg_risk, tick_size))
354    }
355
356    /// Fetch the current fee rate for a token from the API
357    async fn get_fee_rate(&self, token_id: &str) -> Result<String, ClobError> {
358        let resp = self.markets().fee_rate(token_id).send().await?;
359        Ok(resp.base_fee.to_string())
360    }
361
362    /// Resolve the maker address based on funder and signature type
363    async fn resolve_maker_address(
364        &self,
365        funder: Option<Address>,
366        signature_type: SignatureType,
367        account: &Account,
368    ) -> Result<Address, ClobError> {
369        if let Some(funder) = funder {
370            Ok(funder)
371        } else if signature_type.is_proxy() {
372            #[cfg(feature = "gamma")]
373            {
374                // Fetch proxy from Gamma
375                let profile = self
376                    .gamma
377                    .user()
378                    .get(account.address().to_string())
379                    .send()
380                    .await
381                    .map_err(|e| {
382                        ClobError::service(format!("Failed to fetch user profile: {}", e))
383                    })?;
384
385                profile
386                    .proxy
387                    .ok_or_else(|| {
388                        ClobError::validation(format!(
389                            "Signature type {:?} requires proxy, but none found for {}",
390                            signature_type,
391                            account.address()
392                        ))
393                    })?
394                    .parse::<Address>()
395                    .map_err(|e| {
396                        ClobError::validation(format!(
397                            "Invalid proxy address format from Gamma: {}",
398                            e
399                        ))
400                    })
401            }
402            #[cfg(not(feature = "gamma"))]
403            {
404                Err(ClobError::validation(format!(
405                    "Signature type {:?} requires the `gamma` feature to resolve proxy address; \
406                     enable `polyoxide-clob/gamma` or provide an explicit `funder` address",
407                    signature_type
408                )))
409            }
410        } else {
411            Ok(account.address())
412        }
413    }
414
415    /// Build an Order struct from the provided parameters
416    #[allow(clippy::too_many_arguments)]
417    fn build_order(
418        token_id: String,
419        maker: Address,
420        signer: Address,
421        maker_amount: String,
422        taker_amount: String,
423        fee_rate_bps: String,
424        side: OrderSide,
425        signature_type: SignatureType,
426        neg_risk: bool,
427        expiration: Option<u64>,
428    ) -> Order {
429        Order {
430            salt: generate_salt(),
431            maker,
432            signer,
433            taker: alloy::primitives::Address::ZERO,
434            token_id,
435            maker_amount,
436            taker_amount,
437            expiration: expiration.unwrap_or(0).to_string(),
438            nonce: "0".to_string(),
439            fee_rate_bps,
440            side,
441            signature_type,
442            neg_risk,
443        }
444    }
445
446    /// Post multiple signed orders (up to 15)
447    pub async fn post_orders(
448        &self,
449        orders: &[SignedOrderPayload],
450    ) -> Result<Vec<OrderResponse>, ClobError> {
451        let account = self
452            .account
453            .as_ref()
454            .ok_or_else(|| ClobError::validation("Account required to post orders"))?;
455
456        let auth = AuthMode::L2 {
457            address: account.address(),
458            credentials: account.credentials().clone(),
459            signer: account.signer().clone(),
460        };
461
462        let payload: Vec<_> = orders
463            .iter()
464            .map(|o| {
465                serde_json::json!({
466                    "order": o.order,
467                    "owner": account.credentials().key,
468                    "orderType": o.order_type,
469                    "postOnly": o.post_only,
470                })
471            })
472            .collect();
473
474        Request::post(
475            self.http_client.clone(),
476            "/orders".to_string(),
477            auth,
478            self.chain_id,
479        )
480        .body(&payload)?
481        .send()
482        .await
483    }
484
485    /// Post a signed order
486    pub async fn post_order(
487        &self,
488        signed_order: &SignedOrder,
489        order_type: OrderKind,
490        post_only: bool,
491    ) -> Result<OrderResponse, ClobError> {
492        let account = self
493            .account
494            .as_ref()
495            .ok_or_else(|| ClobError::validation("Account required to post order"))?;
496
497        let auth = AuthMode::L2 {
498            address: account.address(),
499            credentials: account.credentials().clone(),
500            signer: account.signer().clone(),
501        };
502
503        // Create the payload wrapping the signed order
504        let payload = serde_json::json!({
505            "order": signed_order,
506            "owner": account.credentials().key,
507            "orderType": order_type,
508            "postOnly": post_only,
509        });
510
511        Request::post(
512            self.http_client.clone(),
513            "/order".to_string(),
514            auth,
515            self.chain_id,
516        )
517        .body(&payload)?
518        .send()
519        .await
520    }
521
522    /// Create, sign, and post an order (convenience method)
523    pub async fn place_order(
524        &self,
525        params: &CreateOrderParams,
526        options: Option<PartialCreateOrderOptions>,
527    ) -> Result<OrderResponse, ClobError> {
528        let order = self.create_order(params, options).await?;
529        let signed_order = self.sign_order(&order).await?;
530        self.post_order(&signed_order, params.order_type, params.post_only)
531            .await
532    }
533
534    /// Create, sign, and post a market order (convenience method)
535    pub async fn place_market_order(
536        &self,
537        params: &MarketOrderArgs,
538        options: Option<PartialCreateOrderOptions>,
539    ) -> Result<OrderResponse, ClobError> {
540        let order = self.create_market_order(params, options).await?;
541        let signed_order = self.sign_order(&order).await?;
542
543        let order_type = params.order_type.unwrap_or(OrderKind::Fok);
544        // Market orders are usually FOK
545
546        self.post_order(&signed_order, order_type, false) // Market orders cannot be post_only
547            .await
548    }
549}
550
551/// Parameters for creating an order
552#[derive(Debug, Clone)]
553pub struct CreateOrderParams {
554    pub token_id: String,
555    pub price: f64,
556    pub size: f64,
557    pub side: OrderSide,
558    pub order_type: OrderKind,
559    pub post_only: bool,
560    pub expiration: Option<u64>,
561    pub funder: Option<Address>,
562    pub signature_type: Option<SignatureType>,
563}
564
565impl CreateOrderParams {
566    /// Validate price and size are finite and within expected ranges.
567    pub fn validate(&self) -> Result<(), ClobError> {
568        if !self.price.is_finite() || !self.size.is_finite() {
569            return Err(ClobError::validation(
570                "Price and size must be finite (no NaN or infinity)",
571            ));
572        }
573        if self.price <= 0.0 || self.price > 1.0 {
574            return Err(ClobError::validation(format!(
575                "Price must be between 0.0 and 1.0, got {}",
576                self.price
577            )));
578        }
579        if self.size <= 0.0 {
580            return Err(ClobError::validation(format!(
581                "Size must be positive, got {}",
582                self.size
583            )));
584        }
585        Ok(())
586    }
587}
588
589/// Payload for batch order submission via [`Clob::post_orders`]
590#[derive(Debug, Clone)]
591pub struct SignedOrderPayload {
592    pub order: SignedOrder,
593    pub order_type: OrderKind,
594    pub post_only: bool,
595}
596
597/// Builder for CLOB client
598pub struct ClobBuilder {
599    base_url: String,
600    timeout_ms: u64,
601    pool_size: usize,
602    chain: Chain,
603    account: Option<Account>,
604    #[cfg(feature = "gamma")]
605    gamma: Option<Gamma>,
606    retry_config: Option<RetryConfig>,
607}
608
609impl ClobBuilder {
610    /// Create a new builder with default configuration
611    pub fn new() -> Self {
612        Self {
613            base_url: DEFAULT_BASE_URL.to_string(),
614            timeout_ms: DEFAULT_TIMEOUT_MS,
615            pool_size: DEFAULT_POOL_SIZE,
616            chain: Chain::PolygonMainnet,
617            account: None,
618            #[cfg(feature = "gamma")]
619            gamma: None,
620            retry_config: None,
621        }
622    }
623
624    /// Set account for the client
625    pub fn with_account(mut self, account: Account) -> Self {
626        self.account = Some(account);
627        self
628    }
629
630    /// Set base URL for the API
631    pub fn base_url(mut self, url: impl Into<String>) -> Self {
632        self.base_url = url.into();
633        self
634    }
635
636    /// Set request timeout in milliseconds
637    pub fn timeout_ms(mut self, timeout: u64) -> Self {
638        self.timeout_ms = timeout;
639        self
640    }
641
642    /// Set connection pool size
643    pub fn pool_size(mut self, size: usize) -> Self {
644        self.pool_size = size;
645        self
646    }
647
648    /// Set chain
649    pub fn chain(mut self, chain: Chain) -> Self {
650        self.chain = chain;
651        self
652    }
653
654    /// Set Gamma client
655    #[cfg(feature = "gamma")]
656    pub fn gamma(mut self, gamma: Gamma) -> Self {
657        self.gamma = Some(gamma);
658        self
659    }
660
661    /// Set retry configuration for 429 responses
662    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
663        self.retry_config = Some(config);
664        self
665    }
666
667    /// Build the CLOB client
668    pub fn build(self) -> Result<Clob, ClobError> {
669        let mut builder = HttpClientBuilder::new(&self.base_url)
670            .timeout_ms(self.timeout_ms)
671            .pool_size(self.pool_size)
672            .with_rate_limiter(RateLimiter::clob_default());
673        if let Some(config) = self.retry_config {
674            builder = builder.with_retry_config(config);
675        }
676        let http_client = builder.build()?;
677
678        #[cfg(feature = "gamma")]
679        let gamma = if let Some(gamma) = self.gamma {
680            gamma
681        } else {
682            polyoxide_gamma::Gamma::builder()
683                .timeout_ms(self.timeout_ms)
684                .pool_size(self.pool_size)
685                .build()
686                .map_err(|e| {
687                    ClobError::service(format!("Failed to build default Gamma client: {}", e))
688                })?
689        };
690
691        Ok(Clob {
692            http_client,
693            chain_id: self.chain.chain_id(),
694            account: self.account,
695            #[cfg(feature = "gamma")]
696            gamma,
697        })
698    }
699}
700
701impl Default for ClobBuilder {
702    fn default() -> Self {
703        Self::new()
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    #[test]
712    fn test_builder_custom_retry_config() {
713        let config = RetryConfig {
714            max_retries: 5,
715            initial_backoff_ms: 1000,
716            max_backoff_ms: 30_000,
717        };
718        let builder = ClobBuilder::new().with_retry_config(config);
719        let config = builder.retry_config.unwrap();
720        assert_eq!(config.max_retries, 5);
721        assert_eq!(config.initial_backoff_ms, 1000);
722    }
723
724    fn make_params(price: f64, size: f64) -> CreateOrderParams {
725        CreateOrderParams {
726            token_id: "test".to_string(),
727            price,
728            size,
729            side: OrderSide::Buy,
730            order_type: OrderKind::Gtc,
731            post_only: false,
732            expiration: None,
733            funder: None,
734            signature_type: None,
735        }
736    }
737
738    #[test]
739    fn test_validate_rejects_nan_price() {
740        let params = make_params(f64::NAN, 100.0);
741        let err = params.validate().unwrap_err();
742        assert!(err.to_string().contains("finite"));
743    }
744
745    #[test]
746    fn test_validate_rejects_nan_size() {
747        let params = make_params(0.5, f64::NAN);
748        let err = params.validate().unwrap_err();
749        assert!(err.to_string().contains("finite"));
750    }
751
752    #[test]
753    fn test_validate_rejects_infinite_price() {
754        let params = make_params(f64::INFINITY, 100.0);
755        let err = params.validate().unwrap_err();
756        assert!(err.to_string().contains("finite"));
757    }
758
759    #[test]
760    fn test_validate_rejects_infinite_size() {
761        let params = make_params(0.5, f64::INFINITY);
762        let err = params.validate().unwrap_err();
763        assert!(err.to_string().contains("finite"));
764    }
765
766    #[test]
767    fn test_validate_rejects_neg_infinity_size() {
768        let params = make_params(0.5, f64::NEG_INFINITY);
769        let err = params.validate().unwrap_err();
770        assert!(err.to_string().contains("finite"));
771    }
772
773    #[test]
774    fn test_validate_rejects_price_out_of_range() {
775        let params = make_params(1.5, 100.0);
776        let err = params.validate().unwrap_err();
777        assert!(err.to_string().contains("between 0.0 and 1.0"));
778    }
779
780    #[test]
781    fn test_validate_rejects_zero_price() {
782        let params = make_params(0.0, 100.0);
783        let err = params.validate().unwrap_err();
784        assert!(err.to_string().contains("between 0.0 and 1.0"));
785    }
786
787    #[test]
788    fn test_validate_rejects_negative_size() {
789        let params = make_params(0.5, -10.0);
790        let err = params.validate().unwrap_err();
791        assert!(err.to_string().contains("positive"));
792    }
793
794    #[test]
795    fn test_validate_accepts_valid_params() {
796        let params = make_params(0.5, 100.0);
797        assert!(params.validate().is_ok());
798    }
799
800    #[test]
801    fn test_validate_accepts_boundary_price() {
802        // Price exactly 1.0 should be valid
803        let params = make_params(1.0, 100.0);
804        assert!(params.validate().is_ok());
805    }
806}