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 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 pub fn public() -> Self {
41 ClobBuilder::new().build().unwrap() }
43
44 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 pub fn from_account(account: Account) -> Result<Self, ClobError> {
55 ClobBuilder::new().with_account(account).build()
56 }
57
58 pub fn account(&self) -> Option<&Account> {
60 self.account.as_ref()
61 }
62
63 pub fn markets(&self) -> Markets {
65 Markets {
66 http_client: self.http_client.clone(),
67 chain_id: self.chain_id,
68 }
69 }
70
71 pub fn health(&self) -> Health {
73 Health {
74 http_client: self.http_client.clone(),
75 }
76 }
77
78 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 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 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 let (neg_risk, tick_size) = self.get_market_metadata(¶ms.token_id, options).await?;
125
126 let fee_rate_bps = self.get_fee_rate(¶ms.token_id).await?;
128
129 let (maker_amount, taker_amount) =
131 calculate_order_amounts(params.price, params.size, params.side, tick_size);
132
133 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 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 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 let (neg_risk, tick_size) = self.get_market_metadata(¶ms.token_id, options).await?;
187
188 let price = if let Some(p) = params.price {
190 p
191 } else {
192 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 let fee_rate_bps = self.get_fee_rate(¶ms.token_id).await?;
210
211 let (maker_amount, taker_amount) =
213 calculate_market_order_amounts(params.amount, price, params.side, tick_size);
214
215 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 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 async fn get_market_metadata(
247 &self,
248 token_id: &str,
249 options: Option<PartialCreateOrderOptions>,
250 ) -> Result<(bool, TickSize), ClobError> {
251 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 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 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 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 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 #[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 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 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 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 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 self.post_order(&signed_order, order_type, false) .await
417 }
418}
419
420#[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
457pub 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 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 pub fn with_account(mut self, account: Account) -> Self {
482 self.account = Some(account);
483 self
484 }
485
486 pub fn base_url(mut self, url: impl Into<String>) -> Self {
488 self.base_url = url.into();
489 self
490 }
491
492 pub fn timeout_ms(mut self, timeout: u64) -> Self {
494 self.timeout_ms = timeout;
495 self
496 }
497
498 pub fn pool_size(mut self, size: usize) -> Self {
500 self.pool_size = size;
501 self
502 }
503
504 pub fn chain(mut self, chain: Chain) -> Self {
506 self.chain = chain;
507 self
508 }
509
510 pub fn gamma(mut self, gamma: Gamma) -> Self {
512 self.gamma = Some(gamma);
513 self
514 }
515
516 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 let params = make_params(1.0, 100.0);
635 assert!(params.validate().is_ok());
636 }
637}