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, tick_size) = self.get_market_metadata(¶ms.token_id, options).await?;
130
131 let fee_rate_bps = self.get_fee_rate().await?;
133
134 let (maker_amount, taker_amount) =
136 calculate_order_amounts(params.price, params.size, params.side, tick_size);
137
138 let signature_type = params.signature_type.unwrap_or_default();
140 let maker = self
141 .resolve_maker_address(params.funder, signature_type, account)
142 .await?;
143
144 Ok(Self::build_order(
146 params.token_id.clone(),
147 maker,
148 account.address(),
149 maker_amount,
150 taker_amount,
151 fee_rate_bps,
152 params.side,
153 signature_type,
154 neg_risk,
155 params.expiration,
156 ))
157 }
158
159 pub async fn create_market_order(
161 &self,
162 params: &MarketOrderArgs,
163 options: Option<PartialCreateOrderOptions>,
164 ) -> Result<Order, ClobError> {
165 let account = self
166 .account
167 .as_ref()
168 .ok_or_else(|| ClobError::validation("Account required to create order"))?;
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
177 let (neg_risk, tick_size) = self.get_market_metadata(¶ms.token_id, options).await?;
179
180 let price = if let Some(p) = params.price {
182 p
183 } else {
184 let book = self
186 .markets()
187 .order_book(params.token_id.clone())
188 .send()
189 .await?;
190
191 let levels = match params.side {
192 OrderSide::Buy => book.asks,
193 OrderSide::Sell => book.bids,
194 };
195
196 calculate_market_price(&levels, params.amount, params.side)
197 .ok_or_else(|| ClobError::validation("Not enough liquidity to fill market order"))?
198 };
199
200 let fee_rate_bps = self.get_fee_rate().await?;
202
203 let (maker_amount, taker_amount) =
205 calculate_market_order_amounts(params.amount, price, params.side, tick_size);
206
207 let signature_type = params.signature_type.unwrap_or_default();
209 let maker = self
210 .resolve_maker_address(params.funder, signature_type, account)
211 .await?;
212
213 Ok(Self::build_order(
215 params.token_id.clone(),
216 maker,
217 account.address(),
218 maker_amount,
219 taker_amount,
220 fee_rate_bps,
221 params.side,
222 signature_type,
223 neg_risk,
224 Some(0),
225 ))
226 }
227 pub async fn sign_order(&self, order: &Order) -> Result<SignedOrder, ClobError> {
228 let account = self
229 .account
230 .as_ref()
231 .ok_or_else(|| ClobError::validation("Account required to sign order"))?;
232 account.sign_order(order, self.chain_id).await
233 }
234
235 async fn get_market_metadata(
239 &self,
240 token_id: &str,
241 options: Option<PartialCreateOrderOptions>,
242 ) -> Result<(bool, TickSize), ClobError> {
243 let neg_risk = if let Some(neg_risk) = options.and_then(|o| o.neg_risk) {
245 neg_risk
246 } else {
247 let neg_risk_resp = self.markets().neg_risk(token_id.to_string()).send().await?;
248 neg_risk_resp.neg_risk
249 };
250
251 let tick_size = if let Some(tick_size) = options.and_then(|o| o.tick_size) {
253 tick_size
254 } else {
255 let tick_size_resp = self
256 .markets()
257 .tick_size(token_id.to_string())
258 .send()
259 .await?;
260 let tick_size_val = tick_size_resp
261 .minimum_tick_size
262 .parse::<f64>()
263 .map_err(|e| {
264 ClobError::validation(format!("Invalid minimum_tick_size field: {}", e))
265 })?;
266 TickSize::try_from(tick_size_val)?
267 };
268
269 Ok((neg_risk, tick_size))
270 }
271
272 async fn get_fee_rate(&self) -> Result<String, ClobError> {
274 let fee_rate_response: serde_json::Value = self
275 .client
276 .get(self.base_url.join("/fee-rate")?)
277 .send()
278 .await?
279 .json()
280 .await?;
281
282 Ok(fee_rate_response["feeRateBps"]
283 .as_str()
284 .unwrap_or("0")
285 .to_string())
286 }
287
288 async fn resolve_maker_address(
290 &self,
291 funder: Option<Address>,
292 signature_type: SignatureType,
293 account: &Account,
294 ) -> Result<Address, ClobError> {
295 if let Some(funder) = funder {
296 Ok(funder)
297 } else if signature_type.is_proxy() {
298 let profile = self
300 .gamma
301 .user()
302 .get(account.address().to_string())
303 .send()
304 .await
305 .map_err(|e| ClobError::service(format!("Failed to fetch user profile: {}", e)))?;
306
307 profile
308 .proxy
309 .ok_or_else(|| {
310 ClobError::validation(format!(
311 "Signature type {:?} requires proxy, but none found for {}",
312 signature_type,
313 account.address()
314 ))
315 })?
316 .parse::<Address>()
317 .map_err(|e| {
318 ClobError::validation(format!("Invalid proxy address format from Gamma: {}", e))
319 })
320 } else {
321 Ok(account.address())
322 }
323 }
324
325 #[allow(clippy::too_many_arguments)]
327 fn build_order(
328 token_id: String,
329 maker: Address,
330 signer: Address,
331 maker_amount: String,
332 taker_amount: String,
333 fee_rate_bps: String,
334 side: OrderSide,
335 signature_type: SignatureType,
336 neg_risk: bool,
337 expiration: Option<u64>,
338 ) -> Order {
339 Order {
340 salt: generate_salt(),
341 maker,
342 signer,
343 taker: alloy::primitives::Address::ZERO,
344 token_id,
345 maker_amount,
346 taker_amount,
347 expiration: expiration.unwrap_or(0).to_string(),
348 nonce: "0".to_string(),
349 fee_rate_bps,
350 side,
351 signature_type,
352 neg_risk,
353 }
354 }
355
356 pub async fn post_order(
359 &self,
360 signed_order: &SignedOrder,
361 order_type: OrderKind,
362 post_only: bool,
363 ) -> Result<OrderResponse, ClobError> {
364 let account = self
365 .account
366 .as_ref()
367 .ok_or_else(|| ClobError::validation("Account required to post order"))?;
368
369 let auth = AuthMode::L2 {
370 address: account.address(),
371 credentials: account.credentials().clone(),
372 signer: account.signer().clone(),
373 };
374
375 let payload = serde_json::json!({
377 "order": signed_order,
378 "owner": account.credentials().key,
379 "orderType": order_type,
380 "postOnly": post_only,
381 });
382
383 Request::post(
384 self.client.clone(),
385 self.base_url.clone(),
386 "/order".to_string(),
387 auth,
388 self.chain_id,
389 )
390 .body(&payload)?
391 .send()
392 .await
393 }
394
395 pub async fn place_order(
397 &self,
398 params: &CreateOrderParams,
399 options: Option<PartialCreateOrderOptions>,
400 ) -> Result<OrderResponse, ClobError> {
401 let order = self.create_order(params, options).await?;
402 let signed_order = self.sign_order(&order).await?;
403 self.post_order(&signed_order, params.order_type, params.post_only)
404 .await
405 }
406
407 pub async fn place_market_order(
409 &self,
410 params: &MarketOrderArgs,
411 options: Option<PartialCreateOrderOptions>,
412 ) -> Result<OrderResponse, ClobError> {
413 let order = self.create_market_order(params, options).await?;
414 let signed_order = self.sign_order(&order).await?;
415
416 let order_type = params.order_type.unwrap_or(OrderKind::Fok);
417 self.post_order(&signed_order, order_type, false) .await
421 }
422}
423
424#[derive(Debug, Clone)]
426pub struct CreateOrderParams {
427 pub token_id: String,
428 pub price: f64,
429 pub size: f64,
430 pub side: OrderSide,
431 pub order_type: OrderKind,
432 pub post_only: bool,
433 pub expiration: Option<u64>,
434 pub funder: Option<Address>,
435 pub signature_type: Option<SignatureType>,
436}
437
438impl CreateOrderParams {
439 pub fn validate(&self) -> Result<(), ClobError> {
440 if self.price <= 0.0 || self.price > 1.0 {
441 return Err(ClobError::validation(format!(
442 "Price must be between 0.0 and 1.0, got {}",
443 self.price
444 )));
445 }
446 if self.size <= 0.0 {
447 return Err(ClobError::validation(format!(
448 "Size must be positive, got {}",
449 self.size
450 )));
451 }
452 if self.price.is_nan() || self.size.is_nan() {
453 return Err(ClobError::validation("NaN values not allowed"));
454 }
455 Ok(())
456 }
457}
458
459pub struct ClobBuilder {
461 base_url: String,
462 timeout_ms: u64,
463 pool_size: usize,
464 chain: Chain,
465 account: Option<Account>,
466 gamma: Option<Gamma>,
467}
468
469impl ClobBuilder {
470 pub fn new() -> Self {
472 Self {
473 base_url: DEFAULT_BASE_URL.to_string(),
474 timeout_ms: DEFAULT_TIMEOUT_MS,
475 pool_size: DEFAULT_POOL_SIZE,
476 chain: Chain::PolygonMainnet,
477 account: None,
478 gamma: None,
479 }
480 }
481
482 pub fn with_account(mut self, account: Account) -> Self {
484 self.account = Some(account);
485 self
486 }
487
488 pub fn base_url(mut self, url: impl Into<String>) -> Self {
490 self.base_url = url.into();
491 self
492 }
493
494 pub fn timeout_ms(mut self, timeout: u64) -> Self {
496 self.timeout_ms = timeout;
497 self
498 }
499
500 pub fn pool_size(mut self, size: usize) -> Self {
502 self.pool_size = size;
503 self
504 }
505
506 pub fn chain(mut self, chain: Chain) -> Self {
508 self.chain = chain;
509 self
510 }
511
512 pub fn gamma(mut self, gamma: Gamma) -> Self {
514 self.gamma = Some(gamma);
515 self
516 }
517
518 pub fn build(self) -> Result<Clob, ClobError> {
520 let HttpClient { client, base_url } = HttpClientBuilder::new(&self.base_url)
521 .timeout_ms(self.timeout_ms)
522 .pool_size(self.pool_size)
523 .build()?;
524
525 let gamma = if let Some(gamma) = self.gamma {
526 gamma
527 } else {
528 polyoxide_gamma::Gamma::builder()
529 .timeout_ms(self.timeout_ms)
530 .pool_size(self.pool_size)
531 .build()
532 .map_err(|e| {
533 ClobError::service(format!("Failed to build default Gamma client: {}", e))
534 })?
535 };
536
537 Ok(Clob {
538 client,
539 base_url,
540 chain_id: self.chain.chain_id(),
541 account: self.account,
542 gamma,
543 })
544 }
545}
546
547impl Default for ClobBuilder {
548 fn default() -> Self {
549 Self::new()
550 }
551}