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,
9        auth::Auth,
10        notifications::Notifications,
11        orders::OrderResponse,
12        rewards::{PublicRewards, Rewards},
13        Health, Markets, Orders,
14    },
15    core::chain::Chain,
16    error::ClobError,
17    request::{AuthMode, Request},
18    types::*,
19    utils::{
20        calculate_market_order_amounts, calculate_market_price, calculate_order_amounts,
21        generate_salt,
22    },
23};
24use alloy::primitives::{Address, B256};
25#[cfg(feature = "gamma")]
26use polyoxide_gamma::Gamma;
27
28const DEFAULT_BASE_URL: &str = "https://clob.polymarket.com";
29
30/// CLOB (Central Limit Order Book) trading client for Polymarket.
31///
32/// Provides authenticated order creation, signing, and submission, plus read-only
33/// market data and order book access. Use [`Clob::public()`] for unauthenticated
34/// read-only access, or [`Clob::builder()`] for full trading capabilities.
35#[derive(Clone)]
36pub struct Clob {
37    pub(crate) http_client: HttpClient,
38    pub(crate) chain_id: u64,
39    pub(crate) signature_type: SignatureType,
40    /// Builder-attribution code stamped into the V2 order `builder` field. Defaults to
41    /// [`B256::ZERO`] (no attribution).
42    pub(crate) builder_code: B256,
43    pub(crate) account: Option<Account>,
44    #[cfg(feature = "gamma")]
45    pub(crate) gamma: Gamma,
46}
47
48impl Clob {
49    /// Create a new CLOB client with default configuration
50    pub fn new(
51        private_key: impl Into<String>,
52        credentials: Credentials,
53    ) -> Result<Self, ClobError> {
54        Self::builder(private_key, credentials)?.build()
55    }
56
57    /// Create a new public CLOB client (read-only)
58    pub fn public() -> Self {
59        ClobBuilder::new().build().unwrap() // unwrap safe because default build never fails
60    }
61
62    /// Create a new CLOB client builder with required authentication
63    pub fn builder(
64        private_key: impl Into<String>,
65        credentials: Credentials,
66    ) -> Result<ClobBuilder, ClobError> {
67        let account = Account::new(private_key, credentials)?;
68        Ok(ClobBuilder::new().with_account(account))
69    }
70
71    /// Create a new CLOB client from an Account
72    pub fn from_account(account: Account) -> Result<Self, ClobError> {
73        ClobBuilder::new().with_account(account).build()
74    }
75
76    /// Get a reference to the account
77    pub fn account(&self) -> Option<&Account> {
78        self.account.as_ref()
79    }
80
81    /// Get markets namespace
82    pub fn markets(&self) -> Markets {
83        Markets {
84            http_client: self.http_client.clone(),
85            chain_id: self.chain_id,
86        }
87    }
88
89    /// Get health namespace for latency and health checks
90    pub fn health(&self) -> Health {
91        Health {
92            http_client: self.http_client.clone(),
93            chain_id: self.chain_id,
94        }
95    }
96
97    /// Get orders namespace
98    pub fn orders(&self) -> Result<Orders, ClobError> {
99        let account = self
100            .account
101            .as_ref()
102            .ok_or_else(|| ClobError::validation("Account required for orders API"))?;
103
104        Ok(Orders {
105            http_client: self.http_client.clone(),
106            wallet: account.wallet().clone(),
107            credentials: account.credentials().clone(),
108            signer: account.signer().clone(),
109            chain_id: self.chain_id,
110        })
111    }
112
113    /// Get account API namespace
114    pub fn account_api(&self) -> Result<AccountApi, ClobError> {
115        let account = self
116            .account
117            .as_ref()
118            .ok_or_else(|| ClobError::validation("Account required for account API"))?;
119
120        Ok(AccountApi {
121            http_client: self.http_client.clone(),
122            wallet: account.wallet().clone(),
123            credentials: account.credentials().clone(),
124            signer: account.signer().clone(),
125            chain_id: self.chain_id,
126            signature_type: self.signature_type,
127        })
128    }
129
130    /// Get notifications namespace
131    pub fn notifications(&self) -> Result<Notifications, ClobError> {
132        let account = self
133            .account
134            .as_ref()
135            .ok_or_else(|| ClobError::validation("Account required for notifications API"))?;
136
137        Ok(Notifications {
138            http_client: self.http_client.clone(),
139            wallet: account.wallet().clone(),
140            credentials: account.credentials().clone(),
141            signer: account.signer().clone(),
142            chain_id: self.chain_id,
143            signature_type: self.signature_type,
144        })
145    }
146
147    /// Get the unauthenticated rewards namespace.
148    ///
149    /// `GET /rewards/markets/current`, `/rewards/markets/{condition_id}`,
150    /// `/rewards/markets/multi`, and `/rebates/current` are public upstream, so
151    /// they are reachable without an account. Use [`Self::rewards`] for the
152    /// L2-authenticated user endpoints.
153    pub fn public_rewards(&self) -> PublicRewards {
154        PublicRewards {
155            http_client: self.http_client.clone(),
156            chain_id: self.chain_id,
157        }
158    }
159
160    /// Get rewards namespace for liquidity reward operations
161    pub fn rewards(&self) -> Result<Rewards, ClobError> {
162        let account = self
163            .account
164            .as_ref()
165            .ok_or_else(|| ClobError::validation("Account required for rewards API"))?;
166
167        Ok(Rewards {
168            http_client: self.http_client.clone(),
169            wallet: account.wallet().clone(),
170            credentials: account.credentials().clone(),
171            signer: account.signer().clone(),
172            chain_id: self.chain_id,
173            signature_type: self.signature_type,
174        })
175    }
176
177    /// Get auth namespace for API key management
178    pub fn auth(&self) -> Result<Auth, ClobError> {
179        let account = self
180            .account
181            .as_ref()
182            .ok_or_else(|| ClobError::validation("Account required for auth API"))?;
183
184        Ok(Auth {
185            http_client: self.http_client.clone(),
186            wallet: account.wallet().clone(),
187            credentials: account.credentials().clone(),
188            signer: account.signer().clone(),
189            chain_id: self.chain_id,
190        })
191    }
192
193    /// Create an unsigned order from parameters
194    pub async fn create_order(
195        &self,
196        params: &CreateOrderParams,
197        options: Option<PartialCreateOrderOptions>,
198    ) -> Result<Order, ClobError> {
199        let account = self
200            .account
201            .as_ref()
202            .ok_or_else(|| ClobError::validation("Account required to create order"))?;
203
204        params.validate()?;
205
206        // Reject Poly1271 before any network I/O (fail fast). This is a UX guard; the
207        // authoritative guarantee lives in `order_to_protocol` (signing choke point).
208        let signature_type = params.signature_type.unwrap_or_default();
209        if signature_type == SignatureType::Poly1271 {
210            return Err(ClobError::validation(
211                "Poly1271 (EIP-1271) signing is not yet supported; use EOA/PolyProxy/PolyGnosisSafe",
212            ));
213        }
214
215        // Fetch market metadata (neg_risk and tick_size)
216        let (neg_risk, tick_size) = self.get_market_metadata(&params.token_id, options).await?;
217
218        // Calculate amounts
219        let (maker_amount, taker_amount) =
220            calculate_order_amounts(params.price, params.size, params.side, tick_size);
221
222        // Resolve maker address
223        let maker = self
224            .resolve_maker_address(params.funder, signature_type, account)
225            .await?;
226
227        let timestamp_ms = std::time::SystemTime::now()
228            .duration_since(std::time::UNIX_EPOCH)
229            .map(|d| d.as_millis())
230            .unwrap_or(0);
231
232        // Build order
233        Ok(Self::build_order_v2(
234            params.token_id.clone(),
235            maker,
236            account.address(),
237            maker_amount,
238            taker_amount,
239            params.side,
240            signature_type,
241            neg_risk,
242            params.expiration,
243            self.builder_code,
244            B256::ZERO,
245            timestamp_ms,
246        ))
247    }
248
249    /// Create an unsigned market order from parameters
250    pub async fn create_market_order(
251        &self,
252        params: &MarketOrderArgs,
253        options: Option<PartialCreateOrderOptions>,
254    ) -> Result<Order, ClobError> {
255        let account = self
256            .account
257            .as_ref()
258            .ok_or_else(|| ClobError::validation("Account required to create order"))?;
259
260        if !params.amount.is_finite() {
261            return Err(ClobError::validation(
262                "Amount must be finite (no NaN or infinity)",
263            ));
264        }
265        if params.amount <= 0.0 {
266            return Err(ClobError::validation(format!(
267                "Amount must be positive, got {}",
268                params.amount
269            )));
270        }
271        if let Some(p) = params.price {
272            if !p.is_finite() || p <= 0.0 || p > 1.0 {
273                return Err(ClobError::validation(format!(
274                    "Price must be finite and between 0.0 and 1.0, got {}",
275                    p
276                )));
277            }
278        }
279
280        // Reject Poly1271 before any network I/O (fail fast). This is a UX guard; the
281        // authoritative guarantee lives in `order_to_protocol` (signing choke point).
282        let signature_type = params.signature_type.unwrap_or_default();
283        if signature_type == SignatureType::Poly1271 {
284            return Err(ClobError::validation(
285                "Poly1271 (EIP-1271) signing is not yet supported; use EOA/PolyProxy/PolyGnosisSafe",
286            ));
287        }
288
289        // Fetch market metadata (neg_risk and tick_size)
290        let (neg_risk, tick_size) = self.get_market_metadata(&params.token_id, options).await?;
291
292        // Determine price
293        let price = if let Some(p) = params.price {
294            p
295        } else {
296            // Fetch orderbook and calculate price
297            let book = self
298                .markets()
299                .order_book(params.token_id.clone())
300                .send()
301                .await?;
302
303            let levels = match params.side {
304                OrderSide::Buy => book.asks,
305                OrderSide::Sell => book.bids,
306            };
307
308            calculate_market_price(&levels, params.amount, params.side)
309                .ok_or_else(|| ClobError::validation("Not enough liquidity to fill market order"))?
310        };
311
312        // Calculate amounts
313        let (maker_amount, taker_amount) =
314            calculate_market_order_amounts(params.amount, price, params.side, tick_size);
315
316        // Resolve maker address
317        let maker = self
318            .resolve_maker_address(params.funder, signature_type, account)
319            .await?;
320
321        let timestamp_ms = std::time::SystemTime::now()
322            .duration_since(std::time::UNIX_EPOCH)
323            .map(|d| d.as_millis())
324            .unwrap_or(0);
325
326        // Build order with expiration set to 0 for market orders
327        Ok(Self::build_order_v2(
328            params.token_id.clone(),
329            maker,
330            account.address(),
331            maker_amount,
332            taker_amount,
333            params.side,
334            signature_type,
335            neg_risk,
336            Some(0),
337            self.builder_code,
338            B256::ZERO,
339            timestamp_ms,
340        ))
341    }
342    /// Sign an order using the configured account's EIP-712 signer.
343    pub async fn sign_order(&self, order: &Order) -> Result<SignedOrder, ClobError> {
344        let account = self
345            .account
346            .as_ref()
347            .ok_or_else(|| ClobError::validation("Account required to sign order"))?;
348        account.sign_order(order, self.chain_id).await
349    }
350
351    // Helper methods for order creation
352
353    /// Fetch market metadata (neg_risk and tick_size) for a token
354    async fn get_market_metadata(
355        &self,
356        token_id: &str,
357        options: Option<PartialCreateOrderOptions>,
358    ) -> Result<(bool, TickSize), ClobError> {
359        // Fetch or use provided neg_risk status
360        let neg_risk = if let Some(neg_risk) = options.and_then(|o| o.neg_risk) {
361            neg_risk
362        } else {
363            let neg_risk_resp = self.markets().neg_risk(token_id.to_string()).send().await?;
364            neg_risk_resp.neg_risk
365        };
366
367        // Fetch or use provided tick size
368        let tick_size = if let Some(tick_size) = options.and_then(|o| o.tick_size) {
369            tick_size
370        } else {
371            let tick_size_resp = self
372                .markets()
373                .tick_size(token_id.to_string())
374                .send()
375                .await?;
376            let tick_size_val = tick_size_resp
377                .minimum_tick_size
378                .parse::<f64>()
379                .map_err(|e| {
380                    ClobError::validation(format!("Invalid minimum_tick_size field: {}", e))
381                })?;
382            TickSize::try_from(tick_size_val)?
383        };
384
385        Ok((neg_risk, tick_size))
386    }
387
388    /// Resolve the maker address based on funder and signature type
389    async fn resolve_maker_address(
390        &self,
391        funder: Option<Address>,
392        signature_type: SignatureType,
393        account: &Account,
394    ) -> Result<Address, ClobError> {
395        if let Some(funder) = funder {
396            Ok(funder)
397        } else if signature_type.is_proxy() {
398            #[cfg(feature = "gamma")]
399            {
400                // Fetch proxy from Gamma
401                let profile = self
402                    .gamma
403                    .user()
404                    .get(account.address().to_string())
405                    .send()
406                    .await
407                    .map_err(|e| {
408                        ClobError::service(format!("Failed to fetch user profile: {}", e))
409                    })?;
410
411                profile
412                    .proxy
413                    .ok_or_else(|| {
414                        ClobError::validation(format!(
415                            "Signature type {:?} requires proxy, but none found for {}",
416                            signature_type,
417                            account.address()
418                        ))
419                    })?
420                    .parse::<Address>()
421                    .map_err(|e| {
422                        ClobError::validation(format!(
423                            "Invalid proxy address format from Gamma: {}",
424                            e
425                        ))
426                    })
427            }
428            #[cfg(not(feature = "gamma"))]
429            {
430                Err(ClobError::validation(format!(
431                    "Signature type {:?} requires the `gamma` feature to resolve proxy address; \
432                     enable `polyoxide-clob/gamma` or provide an explicit `funder` address",
433                    signature_type
434                )))
435            }
436        } else {
437            Ok(account.address())
438        }
439    }
440
441    /// Build a V2 [`Order`] struct from the provided parameters.
442    ///
443    /// `timestamp_ms` supplies the order's uniqueness nonce (Unix ms); `builder` carries the
444    /// builder-attribution code and `metadata` an opaque caller tag (both default to
445    /// [`B256::ZERO`]). `expiration` travels on the wire for GTD orders but is not signed.
446    ///
447    /// Note: the public order-creation paths ([`Clob::create_order`] /
448    /// [`Clob::create_market_order`]) currently always pass `metadata` as [`B256::ZERO`]
449    /// (reserved); builder attribution is set separately via [`ClobBuilder::builder_code`].
450    #[allow(clippy::too_many_arguments)]
451    fn build_order_v2(
452        token_id: String,
453        maker: Address,
454        signer: Address,
455        maker_amount: String,
456        taker_amount: String,
457        side: OrderSide,
458        signature_type: SignatureType,
459        neg_risk: bool,
460        expiration: Option<u64>,
461        builder: B256,
462        metadata: B256,
463        timestamp_ms: u128,
464    ) -> Order {
465        Order {
466            salt: generate_salt(),
467            maker,
468            signer,
469            token_id,
470            maker_amount,
471            taker_amount,
472            side,
473            expiration: expiration.unwrap_or(0).to_string(),
474            signature_type,
475            timestamp: timestamp_ms.to_string(),
476            metadata,
477            builder,
478            neg_risk,
479        }
480    }
481
482    /// Build the JSON submit body wrapping a single signed V2 order.
483    ///
484    /// Nests the V2 `order` under the outer wrapper keys (`owner`, `orderType`, `postOnly`)
485    /// expected by `POST /order` and each element of `POST /orders`.
486    fn order_submit_payload(
487        signed_order: &SignedOrder,
488        order_type: OrderKind,
489        post_only: bool,
490        owner_key: &str,
491    ) -> serde_json::Value {
492        serde_json::json!({
493            "order": signed_order,
494            "owner": owner_key,
495            "orderType": order_type,
496            "postOnly": post_only,
497        })
498    }
499
500    /// Post multiple signed orders (up to 15)
501    pub async fn post_orders(
502        &self,
503        orders: &[SignedOrderPayload],
504    ) -> Result<Vec<OrderResponse>, ClobError> {
505        let account = self
506            .account
507            .as_ref()
508            .ok_or_else(|| ClobError::validation("Account required to post orders"))?;
509
510        let auth = AuthMode::L2 {
511            address: account.address(),
512            credentials: account.credentials().clone(),
513            signer: account.signer().clone(),
514        };
515
516        let payload: Vec<_> = orders
517            .iter()
518            .map(|o| {
519                Self::order_submit_payload(
520                    &o.order,
521                    o.order_type,
522                    o.post_only,
523                    &account.credentials().key,
524                )
525            })
526            .collect();
527
528        Request::post(
529            self.http_client.clone(),
530            "/orders".to_string(),
531            auth,
532            self.chain_id,
533        )
534        .body(&payload)?
535        .send()
536        .await
537    }
538
539    /// Post a signed order
540    pub async fn post_order(
541        &self,
542        signed_order: &SignedOrder,
543        order_type: OrderKind,
544        post_only: bool,
545    ) -> Result<OrderResponse, ClobError> {
546        let account = self
547            .account
548            .as_ref()
549            .ok_or_else(|| ClobError::validation("Account required to post order"))?;
550
551        let auth = AuthMode::L2 {
552            address: account.address(),
553            credentials: account.credentials().clone(),
554            signer: account.signer().clone(),
555        };
556
557        // Create the payload wrapping the signed order
558        let payload = Self::order_submit_payload(
559            signed_order,
560            order_type,
561            post_only,
562            &account.credentials().key,
563        );
564
565        Request::post(
566            self.http_client.clone(),
567            "/order".to_string(),
568            auth,
569            self.chain_id,
570        )
571        .body(&payload)?
572        .send()
573        .await
574    }
575
576    /// Create, sign, and post an order (convenience method)
577    pub async fn place_order(
578        &self,
579        params: &CreateOrderParams,
580        options: Option<PartialCreateOrderOptions>,
581    ) -> Result<OrderResponse, ClobError> {
582        let order = self.create_order(params, options).await?;
583        let signed_order = self.sign_order(&order).await?;
584        self.post_order(&signed_order, params.order_type, params.post_only)
585            .await
586    }
587
588    /// Create, sign, and post a market order (convenience method)
589    pub async fn place_market_order(
590        &self,
591        params: &MarketOrderArgs,
592        options: Option<PartialCreateOrderOptions>,
593    ) -> Result<OrderResponse, ClobError> {
594        let order = self.create_market_order(params, options).await?;
595        let signed_order = self.sign_order(&order).await?;
596
597        let order_type = params.order_type.unwrap_or(OrderKind::Fok);
598        // Market orders are usually FOK
599
600        self.post_order(&signed_order, order_type, false) // Market orders cannot be post_only
601            .await
602    }
603}
604
605/// Parameters for creating an order.
606///
607/// The per-order `metadata` field of the resulting V2 order is currently always
608/// [`B256::ZERO`] (reserved) and is not exposed here; builder attribution is configured
609/// once on the client via [`ClobBuilder::builder_code`], not per order.
610#[derive(Debug, Clone)]
611pub struct CreateOrderParams {
612    pub token_id: String,
613    pub price: f64,
614    pub size: f64,
615    pub side: OrderSide,
616    pub order_type: OrderKind,
617    pub post_only: bool,
618    pub expiration: Option<u64>,
619    pub funder: Option<Address>,
620    pub signature_type: Option<SignatureType>,
621}
622
623impl CreateOrderParams {
624    /// Validate price and size are finite and within expected ranges.
625    pub fn validate(&self) -> Result<(), ClobError> {
626        if !self.price.is_finite() || !self.size.is_finite() {
627            return Err(ClobError::validation(
628                "Price and size must be finite (no NaN or infinity)",
629            ));
630        }
631        if self.price <= 0.0 || self.price > 1.0 {
632            return Err(ClobError::validation(format!(
633                "Price must be between 0.0 and 1.0, got {}",
634                self.price
635            )));
636        }
637        if self.size <= 0.0 {
638            return Err(ClobError::validation(format!(
639                "Size must be positive, got {}",
640                self.size
641            )));
642        }
643        Ok(())
644    }
645}
646
647/// Payload for batch order submission via [`Clob::post_orders`]
648#[derive(Debug, Clone)]
649pub struct SignedOrderPayload {
650    pub order: SignedOrder,
651    pub order_type: OrderKind,
652    pub post_only: bool,
653}
654
655/// Builder for CLOB client
656pub struct ClobBuilder {
657    base_url: String,
658    timeout_ms: u64,
659    pool_size: usize,
660    chain: Chain,
661    signature_type: SignatureType,
662    builder_code: B256,
663    account: Option<Account>,
664    #[cfg(feature = "gamma")]
665    gamma: Option<Gamma>,
666    retry_config: Option<RetryConfig>,
667    max_concurrent: Option<usize>,
668}
669
670impl ClobBuilder {
671    /// Create a new builder with default configuration
672    pub fn new() -> Self {
673        Self {
674            base_url: DEFAULT_BASE_URL.to_string(),
675            timeout_ms: DEFAULT_TIMEOUT_MS,
676            pool_size: DEFAULT_POOL_SIZE,
677            chain: Chain::PolygonMainnet,
678            signature_type: SignatureType::Eoa,
679            builder_code: B256::ZERO,
680            account: None,
681            #[cfg(feature = "gamma")]
682            gamma: None,
683            retry_config: None,
684            max_concurrent: None,
685        }
686    }
687
688    /// Set account for the client
689    pub fn with_account(mut self, account: Account) -> Self {
690        self.account = Some(account);
691        self
692    }
693
694    /// Set base URL for the API
695    pub fn base_url(mut self, url: impl Into<String>) -> Self {
696        self.base_url = url.into();
697        self
698    }
699
700    /// Set request timeout in milliseconds
701    pub fn timeout_ms(mut self, timeout: u64) -> Self {
702        self.timeout_ms = timeout;
703        self
704    }
705
706    /// Set connection pool size
707    pub fn pool_size(mut self, size: usize) -> Self {
708        self.pool_size = size;
709        self
710    }
711
712    /// Set chain
713    pub fn chain(mut self, chain: Chain) -> Self {
714        self.chain = chain;
715        self
716    }
717
718    /// Set the account signature type used to derive the on-chain address for
719    /// authenticated read endpoints (balances, notifications, rewards).
720    ///
721    /// Defaults to [`SignatureType::Eoa`]. Set this to [`SignatureType::PolyProxy`]
722    /// or [`SignatureType::PolyGnosisSafe`] when the API credentials belong to a
723    /// Polymarket proxy / Gnosis Safe wallet, so the server resolves the correct
724    /// address rather than the bare EOA.
725    pub fn signature_type(mut self, signature_type: SignatureType) -> Self {
726        self.signature_type = signature_type;
727        self
728    }
729
730    /// Set the builder-attribution code stamped into the `builder` field of every V2 order
731    /// created by this client.
732    ///
733    /// Defaults to [`B256::ZERO`] (no attribution). Set this to the 32-byte code issued to
734    /// your integration to attribute order flow (and any associated builder fees collected
735    /// on-chain at match time).
736    ///
737    /// # Examples
738    ///
739    /// ```no_run
740    /// use alloy::primitives::B256;
741    /// use polyoxide_clob::{Account, ClobBuilder};
742    ///
743    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
744    /// let account = Account::from_env()?;
745    /// // Stamp the builder code issued to your integration onto every order.
746    /// let builder_code: B256 = "0x1111111111111111111111111111111111111111111111111111111111111111"
747    ///     .parse()?;
748    /// let clob = ClobBuilder::new()
749    ///     .with_account(account)
750    ///     .builder_code(builder_code)
751    ///     .build()?;
752    /// # let _ = clob;
753    /// # Ok(())
754    /// # }
755    /// ```
756    pub fn builder_code(mut self, code: B256) -> Self {
757        self.builder_code = code;
758        self
759    }
760
761    /// Set Gamma client
762    #[cfg(feature = "gamma")]
763    pub fn gamma(mut self, gamma: Gamma) -> Self {
764        self.gamma = Some(gamma);
765        self
766    }
767
768    /// Set retry configuration for 429 responses
769    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
770        self.retry_config = Some(config);
771        self
772    }
773
774    /// Set the maximum number of concurrent in-flight requests.
775    ///
776    /// Default: 8. Prevents Cloudflare 1015 errors from request bursts.
777    pub fn max_concurrent(mut self, max: usize) -> Self {
778        self.max_concurrent = Some(max);
779        self
780    }
781
782    /// Build the CLOB client
783    pub fn build(self) -> Result<Clob, ClobError> {
784        let mut builder = HttpClientBuilder::new(&self.base_url)
785            .timeout_ms(self.timeout_ms)
786            .pool_size(self.pool_size)
787            .with_rate_limiter(RateLimiter::clob_default())
788            .with_max_concurrent(self.max_concurrent.unwrap_or(8));
789        if let Some(config) = self.retry_config {
790            builder = builder.with_retry_config(config);
791        }
792        let http_client = builder.build()?;
793
794        #[cfg(feature = "gamma")]
795        let gamma = if let Some(gamma) = self.gamma {
796            gamma
797        } else {
798            polyoxide_gamma::Gamma::builder()
799                .timeout_ms(self.timeout_ms)
800                .pool_size(self.pool_size)
801                .build()
802                .map_err(|e| {
803                    ClobError::service(format!("Failed to build default Gamma client: {}", e))
804                })?
805        };
806
807        Ok(Clob {
808            http_client,
809            chain_id: self.chain.chain_id(),
810            signature_type: self.signature_type,
811            builder_code: self.builder_code,
812            account: self.account,
813            #[cfg(feature = "gamma")]
814            gamma,
815        })
816    }
817}
818
819impl Default for ClobBuilder {
820    fn default() -> Self {
821        Self::new()
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    #[test]
830    fn build_order_v2_sets_builder_and_timestamp() {
831        let code = alloy::primitives::B256::from([0x11u8; 32]);
832        let order = Clob::build_order_v2(
833            "100".to_string(),
834            alloy::primitives::Address::ZERO,
835            alloy::primitives::Address::ZERO,
836            "1000000".to_string(),
837            "5000000".to_string(),
838            OrderSide::Buy,
839            SignatureType::PolyProxy,
840            false,
841            Some(0),
842            code,
843            alloy::primitives::B256::ZERO,
844            1_700_000_000_000,
845        );
846        assert_eq!(order.builder, code);
847        assert_eq!(order.metadata, alloy::primitives::B256::ZERO);
848        assert_eq!(order.timestamp, "1700000000000");
849        assert_eq!(order.expiration, "0");
850    }
851
852    #[test]
853    fn post_order_payload_is_v2_wrapper() {
854        let signed = SignedOrder {
855            order: Order {
856                salt: "1".into(),
857                maker: Address::ZERO,
858                signer: Address::ZERO,
859                token_id: "100".into(),
860                maker_amount: "1".into(),
861                taker_amount: "1".into(),
862                side: OrderSide::Buy,
863                expiration: "0".into(),
864                signature_type: SignatureType::PolyProxy,
865                timestamp: "1700000000000".into(),
866                metadata: alloy::primitives::B256::ZERO,
867                builder: alloy::primitives::B256::from([0x11u8; 32]),
868                neg_risk: false,
869            },
870            signature: "0xabc".into(),
871        };
872        let payload = Clob::order_submit_payload(&signed, OrderKind::Gtc, false, "owner-key");
873        assert_eq!(payload["owner"], "owner-key");
874        assert_eq!(payload["orderType"], "GTC");
875        assert_eq!(payload["postOnly"], false);
876        assert_eq!(payload["order"]["builder"].as_str().unwrap().len(), 66);
877        assert!(payload["order"]["timestamp"].is_string());
878    }
879
880    #[test]
881    fn test_builder_custom_max_concurrent() {
882        let builder = ClobBuilder::new().max_concurrent(16);
883        assert_eq!(builder.max_concurrent, Some(16));
884    }
885
886    #[tokio::test]
887    async fn test_default_concurrency_limit_is_8() {
888        let clob = Clob::public();
889        let mut permits = Vec::new();
890        for _ in 0..8 {
891            permits.push(clob.http_client.acquire_concurrency().await);
892        }
893        assert!(permits.iter().all(|p| p.is_some()));
894
895        let result = tokio::time::timeout(
896            std::time::Duration::from_millis(50),
897            clob.http_client.acquire_concurrency(),
898        )
899        .await;
900        assert!(
901            result.is_err(),
902            "9th permit should block with default limit of 8"
903        );
904    }
905
906    #[test]
907    fn test_builder_custom_retry_config() {
908        let config = RetryConfig {
909            max_retries: 5,
910            initial_backoff_ms: 1000,
911            max_backoff_ms: 30_000,
912        };
913        let builder = ClobBuilder::new().with_retry_config(config);
914        let config = builder.retry_config.unwrap();
915        assert_eq!(config.max_retries, 5);
916        assert_eq!(config.initial_backoff_ms, 1000);
917    }
918
919    fn make_params(price: f64, size: f64) -> CreateOrderParams {
920        CreateOrderParams {
921            token_id: "test".to_string(),
922            price,
923            size,
924            side: OrderSide::Buy,
925            order_type: OrderKind::Gtc,
926            post_only: false,
927            expiration: None,
928            funder: None,
929            signature_type: None,
930        }
931    }
932
933    #[test]
934    fn test_validate_rejects_nan_price() {
935        let params = make_params(f64::NAN, 100.0);
936        let err = params.validate().unwrap_err();
937        assert!(err.to_string().contains("finite"));
938    }
939
940    #[test]
941    fn test_validate_rejects_nan_size() {
942        let params = make_params(0.5, f64::NAN);
943        let err = params.validate().unwrap_err();
944        assert!(err.to_string().contains("finite"));
945    }
946
947    #[test]
948    fn test_validate_rejects_infinite_price() {
949        let params = make_params(f64::INFINITY, 100.0);
950        let err = params.validate().unwrap_err();
951        assert!(err.to_string().contains("finite"));
952    }
953
954    #[test]
955    fn test_validate_rejects_infinite_size() {
956        let params = make_params(0.5, f64::INFINITY);
957        let err = params.validate().unwrap_err();
958        assert!(err.to_string().contains("finite"));
959    }
960
961    #[test]
962    fn test_validate_rejects_neg_infinity_size() {
963        let params = make_params(0.5, f64::NEG_INFINITY);
964        let err = params.validate().unwrap_err();
965        assert!(err.to_string().contains("finite"));
966    }
967
968    #[test]
969    fn test_validate_rejects_price_out_of_range() {
970        let params = make_params(1.5, 100.0);
971        let err = params.validate().unwrap_err();
972        assert!(err.to_string().contains("between 0.0 and 1.0"));
973    }
974
975    #[test]
976    fn test_validate_rejects_zero_price() {
977        let params = make_params(0.0, 100.0);
978        let err = params.validate().unwrap_err();
979        assert!(err.to_string().contains("between 0.0 and 1.0"));
980    }
981
982    #[test]
983    fn test_validate_rejects_negative_size() {
984        let params = make_params(0.5, -10.0);
985        let err = params.validate().unwrap_err();
986        assert!(err.to_string().contains("positive"));
987    }
988
989    #[test]
990    fn test_validate_accepts_valid_params() {
991        let params = make_params(0.5, 100.0);
992        assert!(params.validate().is_ok());
993    }
994
995    #[test]
996    fn test_validate_accepts_boundary_price() {
997        // Price exactly 1.0 should be valid
998        let params = make_params(1.0, 100.0);
999        assert!(params.validate().is_ok());
1000    }
1001}