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