Skip to main content

polyoxide_clob/
client.rs

1use polyoxide_core::{
2    HttpClient, HttpClientBuilder, RateLimiter, 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}
466
467impl ClobBuilder {
468    /// Create a new builder with default configuration
469    pub fn new() -> Self {
470        Self {
471            base_url: DEFAULT_BASE_URL.to_string(),
472            timeout_ms: DEFAULT_TIMEOUT_MS,
473            pool_size: DEFAULT_POOL_SIZE,
474            chain: Chain::PolygonMainnet,
475            account: None,
476            gamma: None,
477        }
478    }
479
480    /// Set account for the client
481    pub fn with_account(mut self, account: Account) -> Self {
482        self.account = Some(account);
483        self
484    }
485
486    /// Set base URL for the API
487    pub fn base_url(mut self, url: impl Into<String>) -> Self {
488        self.base_url = url.into();
489        self
490    }
491
492    /// Set request timeout in milliseconds
493    pub fn timeout_ms(mut self, timeout: u64) -> Self {
494        self.timeout_ms = timeout;
495        self
496    }
497
498    /// Set connection pool size
499    pub fn pool_size(mut self, size: usize) -> Self {
500        self.pool_size = size;
501        self
502    }
503
504    /// Set chain
505    pub fn chain(mut self, chain: Chain) -> Self {
506        self.chain = chain;
507        self
508    }
509
510    /// Set Gamma client
511    pub fn gamma(mut self, gamma: Gamma) -> Self {
512        self.gamma = Some(gamma);
513        self
514    }
515
516    /// Build the CLOB client
517    pub fn build(self) -> Result<Clob, ClobError> {
518        let http_client = HttpClientBuilder::new(&self.base_url)
519            .timeout_ms(self.timeout_ms)
520            .pool_size(self.pool_size)
521            .with_rate_limiter(RateLimiter::clob_default())
522            .build()?;
523
524        let gamma = if let Some(gamma) = self.gamma {
525            gamma
526        } else {
527            polyoxide_gamma::Gamma::builder()
528                .timeout_ms(self.timeout_ms)
529                .pool_size(self.pool_size)
530                .build()
531                .map_err(|e| {
532                    ClobError::service(format!("Failed to build default Gamma client: {}", e))
533                })?
534        };
535
536        Ok(Clob {
537            http_client,
538            chain_id: self.chain.chain_id(),
539            account: self.account,
540            gamma,
541        })
542    }
543}
544
545impl Default for ClobBuilder {
546    fn default() -> Self {
547        Self::new()
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    fn make_params(price: f64, size: f64) -> CreateOrderParams {
556        CreateOrderParams {
557            token_id: "test".to_string(),
558            price,
559            size,
560            side: OrderSide::Buy,
561            order_type: OrderKind::Gtc,
562            post_only: false,
563            expiration: None,
564            funder: None,
565            signature_type: None,
566        }
567    }
568
569    #[test]
570    fn test_validate_rejects_nan_price() {
571        let params = make_params(f64::NAN, 100.0);
572        let err = params.validate().unwrap_err();
573        assert!(err.to_string().contains("finite"));
574    }
575
576    #[test]
577    fn test_validate_rejects_nan_size() {
578        let params = make_params(0.5, f64::NAN);
579        let err = params.validate().unwrap_err();
580        assert!(err.to_string().contains("finite"));
581    }
582
583    #[test]
584    fn test_validate_rejects_infinite_price() {
585        let params = make_params(f64::INFINITY, 100.0);
586        let err = params.validate().unwrap_err();
587        assert!(err.to_string().contains("finite"));
588    }
589
590    #[test]
591    fn test_validate_rejects_infinite_size() {
592        let params = make_params(0.5, f64::INFINITY);
593        let err = params.validate().unwrap_err();
594        assert!(err.to_string().contains("finite"));
595    }
596
597    #[test]
598    fn test_validate_rejects_neg_infinity_size() {
599        let params = make_params(0.5, f64::NEG_INFINITY);
600        let err = params.validate().unwrap_err();
601        assert!(err.to_string().contains("finite"));
602    }
603
604    #[test]
605    fn test_validate_rejects_price_out_of_range() {
606        let params = make_params(1.5, 100.0);
607        let err = params.validate().unwrap_err();
608        assert!(err.to_string().contains("between 0.0 and 1.0"));
609    }
610
611    #[test]
612    fn test_validate_rejects_zero_price() {
613        let params = make_params(0.0, 100.0);
614        let err = params.validate().unwrap_err();
615        assert!(err.to_string().contains("between 0.0 and 1.0"));
616    }
617
618    #[test]
619    fn test_validate_rejects_negative_size() {
620        let params = make_params(0.5, -10.0);
621        let err = params.validate().unwrap_err();
622        assert!(err.to_string().contains("positive"));
623    }
624
625    #[test]
626    fn test_validate_accepts_valid_params() {
627        let params = make_params(0.5, 100.0);
628        assert!(params.validate().is_ok());
629    }
630
631    #[test]
632    fn test_validate_accepts_boundary_price() {
633        // Price exactly 1.0 should be valid
634        let params = make_params(1.0, 100.0);
635        assert!(params.validate().is_ok());
636    }
637}