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