1use polyoxide_core::{HttpClient, HttpClientBuilder, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS};
2use reqwest::Client;
3use url::Url;
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) client: Client,
25 pub(crate) base_url: Url,
26 pub(crate) chain_id: u64,
27 pub(crate) account: Option<Account>,
28 pub(crate) gamma: Gamma,
29}
30
31impl Clob {
32 pub fn new(
34 private_key: impl Into<String>,
35 credentials: Credentials,
36 ) -> Result<Self, ClobError> {
37 Self::builder(private_key, credentials)?.build()
38 }
39
40 pub fn public() -> Self {
42 ClobBuilder::new().build().unwrap() }
44
45 pub fn builder(
47 private_key: impl Into<String>,
48 credentials: Credentials,
49 ) -> Result<ClobBuilder, ClobError> {
50 let account = Account::new(private_key, credentials)?;
51 Ok(ClobBuilder::new().with_account(account))
52 }
53
54 pub fn from_account(account: Account) -> Result<Self, ClobError> {
56 ClobBuilder::new().with_account(account).build()
57 }
58
59 pub fn account(&self) -> Option<&Account> {
61 self.account.as_ref()
62 }
63
64 pub fn markets(&self) -> Markets {
66 Markets {
67 client: self.client.clone(),
68 base_url: self.base_url.clone(),
69 chain_id: self.chain_id,
70 }
71 }
72
73 pub fn health(&self) -> Health {
75 Health {
76 client: self.client.clone(),
77 base_url: self.base_url.clone(),
78 }
79 }
80
81 pub fn orders(&self) -> Result<Orders, ClobError> {
83 let account = self
84 .account
85 .as_ref()
86 .ok_or_else(|| ClobError::validation("Account required for orders API"))?;
87
88 Ok(Orders {
89 client: self.client.clone(),
90 base_url: self.base_url.clone(),
91 wallet: account.wallet().clone(),
92 credentials: account.credentials().clone(),
93 signer: account.signer().clone(),
94 chain_id: self.chain_id,
95 })
96 }
97
98 pub fn account_api(&self) -> Result<AccountApi, ClobError> {
100 let account = self
101 .account
102 .as_ref()
103 .ok_or_else(|| ClobError::validation("Account required for account API"))?;
104
105 Ok(AccountApi {
106 client: self.client.clone(),
107 base_url: self.base_url.clone(),
108 wallet: account.wallet().clone(),
109 credentials: account.credentials().clone(),
110 signer: account.signer().clone(),
111 chain_id: self.chain_id,
112 })
113 }
114
115 pub async fn create_order(
117 &self,
118 params: &CreateOrderParams,
119 options: Option<PartialCreateOrderOptions>,
120 ) -> Result<Order, ClobError> {
121 let account = self
122 .account
123 .as_ref()
124 .ok_or_else(|| ClobError::validation("Account required to create order"))?;
125
126 params.validate()?;
127
128 let neg_risk = if let Some(neg_risk) = options.and_then(|o| o.neg_risk) {
130 neg_risk
131 } else {
132 let neg_risk_resp = self
133 .markets()
134 .neg_risk(params.token_id.clone())
135 .send()
136 .await?;
137 neg_risk_resp.neg_risk
138 };
139
140 let tick_size = if let Some(tick_size) = options.and_then(|o| o.tick_size) {
142 tick_size
143 } else {
144 let tick_size_resp = self
145 .markets()
146 .tick_size(params.token_id.clone())
147 .send()
148 .await?;
149 let tick_size_val = tick_size_resp
150 .minimum_tick_size
151 .parse::<f64>()
152 .map_err(|e| {
153 ClobError::validation(format!("Invalid minimum_tick_size field: {}", e))
154 })?;
155 TickSize::try_from(tick_size_val)?
156 };
157
158 let fee_rate_response: serde_json::Value = self
160 .client
161 .get(self.base_url.join("/fee-rate")?)
162 .send()
163 .await?
164 .json()
165 .await?;
166
167 let fee_rate_bps = fee_rate_response["feeRateBps"]
168 .as_str()
169 .unwrap_or("0")
170 .to_string();
171
172 let (maker_amount, taker_amount) =
174 calculate_order_amounts(params.price, params.size, params.side, tick_size);
175
176 let signature_type = params.signature_type.unwrap_or_default();
177 let maker = if let Some(funder) = params.funder {
178 funder
179 } else if signature_type.is_proxy() {
180 let profile = self
182 .gamma
183 .user()
184 .get(account.address().to_string())
185 .send()
186 .await
187 .map_err(|e| ClobError::service(format!("Failed to fetch user profile: {}", e)))?;
188
189 profile
190 .proxy
191 .ok_or_else(|| {
192 ClobError::validation(format!(
193 "Signature type {:?} requires proxy, but none found for {}",
194 signature_type,
195 account.address()
196 ))
197 })?
198 .parse::<Address>()
199 .map_err(|e| {
200 ClobError::validation(format!("Invalid proxy address format from Gamma: {}", e))
201 })?
202 } else {
203 account.address()
204 };
205
206 Ok(Order {
207 salt: generate_salt(),
208 maker,
209 signer: account.address(),
210 taker: alloy::primitives::Address::ZERO,
211 token_id: params.token_id.clone(),
212 maker_amount,
213 taker_amount,
214 expiration: params.expiration.unwrap_or(0).to_string(),
215 nonce: "0".to_string(),
216 fee_rate_bps,
217 side: params.side,
218 signature_type,
219 neg_risk,
220 })
221 }
222
223 pub async fn create_market_order(
225 &self,
226 params: &MarketOrderArgs,
227 options: Option<PartialCreateOrderOptions>,
228 ) -> Result<Order, ClobError> {
229 let account = self
230 .account
231 .as_ref()
232 .ok_or_else(|| ClobError::validation("Account required to create order"))?;
233
234 if params.amount <= 0.0 {
235 return Err(ClobError::validation(format!(
236 "Amount must be positive, got {}",
237 params.amount
238 )));
239 }
240
241 let neg_risk = if let Some(neg_risk) = options.and_then(|o| o.neg_risk) {
243 neg_risk
244 } else {
245 let neg_risk_resp = self
246 .markets()
247 .neg_risk(params.token_id.clone())
248 .send()
249 .await?;
250 neg_risk_resp.neg_risk
251 };
252
253 let tick_size = if let Some(tick_size) = options.and_then(|o| o.tick_size) {
255 tick_size
256 } else {
257 let tick_size_resp = self
258 .markets()
259 .tick_size(params.token_id.clone())
260 .send()
261 .await?;
262 let tick_size_val = tick_size_resp
263 .minimum_tick_size
264 .parse::<f64>()
265 .map_err(|e| {
266 ClobError::validation(format!("Invalid minimum_tick_size field: {}", e))
267 })?;
268 TickSize::try_from(tick_size_val)?
269 };
270
271 let price = if let Some(p) = params.price {
273 p
274 } else {
275 let book = self
277 .markets()
278 .order_book(params.token_id.clone())
279 .send()
280 .await?;
281
282 let levels = match params.side {
283 OrderSide::Buy => book.asks,
284 OrderSide::Sell => book.bids,
285 };
286
287 calculate_market_price(&levels, params.amount, params.side)
288 .ok_or_else(|| ClobError::validation("Not enough liquidity to fill market order"))?
289 };
290
291 let fee_rate_response: serde_json::Value = self
293 .client
294 .get(self.base_url.join("/fee-rate")?)
295 .send()
296 .await?
297 .json()
298 .await?;
299
300 let fee_rate_bps = fee_rate_response["feeRateBps"]
301 .as_str()
302 .unwrap_or("0")
303 .to_string();
304
305 let (maker_amount, taker_amount) =
307 calculate_market_order_amounts(params.amount, price, params.side, tick_size);
308
309 let signature_type = params.signature_type.unwrap_or_default();
310 let maker = if let Some(funder) = params.funder {
311 funder
312 } else if signature_type.is_proxy() {
313 let profile = self
315 .gamma
316 .user()
317 .get(account.address().to_string())
318 .send()
319 .await
320 .map_err(|e| ClobError::service(format!("Failed to fetch user profile: {}", e)))?;
321
322 profile
323 .proxy
324 .ok_or_else(|| {
325 ClobError::validation(format!(
326 "Signature type {:?} requires proxy, but none found for {}",
327 signature_type,
328 account.address()
329 ))
330 })?
331 .parse::<Address>()
332 .map_err(|e| {
333 ClobError::validation(format!("Invalid proxy address format from Gamma: {}", e))
334 })?
335 } else {
336 account.address()
337 };
338
339 Ok(Order {
340 salt: generate_salt(),
341 maker,
342 signer: account.address(),
343 taker: alloy::primitives::Address::ZERO,
344 token_id: params.token_id.clone(),
345 maker_amount,
346 taker_amount,
347 expiration: "0".to_string(),
350 nonce: "0".to_string(),
351 fee_rate_bps,
352 side: params.side,
353 signature_type,
354 neg_risk,
355 })
356 }
357 pub async fn sign_order(&self, order: &Order) -> Result<SignedOrder, ClobError> {
358 let account = self
359 .account
360 .as_ref()
361 .ok_or_else(|| ClobError::validation("Account required to sign order"))?;
362 account.sign_order(order, self.chain_id).await
363 }
364
365 pub async fn post_order(
368 &self,
369 signed_order: &SignedOrder,
370 order_type: OrderKind,
371 post_only: bool,
372 ) -> Result<OrderResponse, ClobError> {
373 let account = self
374 .account
375 .as_ref()
376 .ok_or_else(|| ClobError::validation("Account required to post order"))?;
377
378 let auth = AuthMode::L2 {
379 address: account.address(),
380 credentials: account.credentials().clone(),
381 signer: account.signer().clone(),
382 };
383
384 let payload = serde_json::json!({
386 "order": signed_order,
387 "owner": account.credentials().key,
388 "orderType": order_type,
389 "postOnly": post_only,
390 });
391
392 Request::post(
393 self.client.clone(),
394 self.base_url.clone(),
395 "/order".to_string(),
396 auth,
397 self.chain_id,
398 )
399 .body(&payload)?
400 .send()
401 .await
402 }
403
404 pub async fn place_order(
406 &self,
407 params: &CreateOrderParams,
408 options: Option<PartialCreateOrderOptions>,
409 ) -> Result<OrderResponse, ClobError> {
410 let order = self.create_order(params, options).await?;
411 let signed_order = self.sign_order(&order).await?;
412 self.post_order(&signed_order, params.order_type, params.post_only)
413 .await
414 }
415
416 pub async fn place_market_order(
418 &self,
419 params: &MarketOrderArgs,
420 options: Option<PartialCreateOrderOptions>,
421 ) -> Result<OrderResponse, ClobError> {
422 let order = self.create_market_order(params, options).await?;
423 let signed_order = self.sign_order(&order).await?;
424
425 let order_type = params.order_type.unwrap_or(OrderKind::Fok);
426 self.post_order(&signed_order, order_type, false) .await
430 }
431}
432
433#[derive(Debug, Clone)]
435pub struct CreateOrderParams {
436 pub token_id: String,
437 pub price: f64,
438 pub size: f64,
439 pub side: OrderSide,
440 pub order_type: OrderKind,
441 pub post_only: bool,
442 pub expiration: Option<u64>,
443 pub funder: Option<Address>,
444 pub signature_type: Option<SignatureType>,
445}
446
447impl CreateOrderParams {
448 pub fn validate(&self) -> Result<(), ClobError> {
449 if self.price <= 0.0 || self.price > 1.0 {
450 return Err(ClobError::validation(format!(
451 "Price must be between 0.0 and 1.0, got {}",
452 self.price
453 )));
454 }
455 if self.size <= 0.0 {
456 return Err(ClobError::validation(format!(
457 "Size must be positive, got {}",
458 self.size
459 )));
460 }
461 if self.price.is_nan() || self.size.is_nan() {
462 return Err(ClobError::validation("NaN values not allowed"));
463 }
464 Ok(())
465 }
466}
467
468pub struct ClobBuilder {
470 base_url: String,
471 timeout_ms: u64,
472 pool_size: usize,
473 chain: Chain,
474 account: Option<Account>,
475 gamma: Option<Gamma>,
476}
477
478impl ClobBuilder {
479 pub fn new() -> Self {
481 Self {
482 base_url: DEFAULT_BASE_URL.to_string(),
483 timeout_ms: DEFAULT_TIMEOUT_MS,
484 pool_size: DEFAULT_POOL_SIZE,
485 chain: Chain::PolygonMainnet,
486 account: None,
487 gamma: None,
488 }
489 }
490
491 pub fn with_account(mut self, account: Account) -> Self {
493 self.account = Some(account);
494 self
495 }
496
497 pub fn base_url(mut self, url: impl Into<String>) -> Self {
499 self.base_url = url.into();
500 self
501 }
502
503 pub fn timeout_ms(mut self, timeout: u64) -> Self {
505 self.timeout_ms = timeout;
506 self
507 }
508
509 pub fn pool_size(mut self, size: usize) -> Self {
511 self.pool_size = size;
512 self
513 }
514
515 pub fn chain(mut self, chain: Chain) -> Self {
517 self.chain = chain;
518 self
519 }
520
521 pub fn gamma(mut self, gamma: Gamma) -> Self {
523 self.gamma = Some(gamma);
524 self
525 }
526
527 pub fn build(self) -> Result<Clob, ClobError> {
529 let HttpClient { client, base_url } = HttpClientBuilder::new(&self.base_url)
530 .timeout_ms(self.timeout_ms)
531 .pool_size(self.pool_size)
532 .build()?;
533
534 let gamma = if let Some(gamma) = self.gamma {
535 gamma
536 } else {
537 polyoxide_gamma::Gamma::builder()
538 .timeout_ms(self.timeout_ms)
539 .pool_size(self.pool_size)
540 .build()
541 .map_err(|e| {
542 ClobError::service(format!("Failed to build default Gamma client: {}", e))
543 })?
544 };
545
546 Ok(Clob {
547 client,
548 base_url,
549 chain_id: self.chain.chain_id(),
550 account: self.account,
551 gamma,
552 })
553 }
554}
555
556impl Default for ClobBuilder {
557 fn default() -> Self {
558 Self::new()
559 }
560}