1use {
7 crate::error::Result,
8 base64::{Engine, engine::general_purpose::STANDARD},
9 hmac::{Hmac, Mac},
10 reqwest::header::{HeaderMap, HeaderValue},
11 serde::{Deserialize, Serialize},
12 sha2::Sha256,
13 std::time::{SystemTime, UNIX_EPOCH},
14};
15
16#[cfg(feature = "tracing")]
18macro_rules! log_info {
19 ($($arg:tt)*) => { tracing::info!($($arg)*) };
20}
21
22#[cfg(not(feature = "tracing"))]
23macro_rules! log_info {
24 ($($arg:tt)*) => {};
25}
26
27#[cfg(feature = "tracing")]
29macro_rules! log_debug {
30 ($($arg:tt)*) => { tracing::debug!($($arg)*) };
31}
32
33#[cfg(not(feature = "tracing"))]
34macro_rules! log_debug {
35 ($($arg:tt)*) => {};
36}
37
38const CLOB_API_BASE: &str = "https://clob.polymarket.com";
39
40#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
42#[serde(rename_all = "UPPERCASE")]
43pub enum Side {
44 Buy,
45 Sell,
46}
47
48#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
50#[serde(rename_all = "UPPERCASE")]
51pub enum OrderType {
52 Limit,
53 Market,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum OrderStatus {
60 Open,
61 Filled,
62 Cancelled,
63 Rejected,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct PriceLevel {
69 pub price: String,
70 pub size: String,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Orderbook {
76 pub bids: Vec<PriceLevel>,
77 pub asks: Vec<PriceLevel>,
78 #[serde(default)]
80 pub market: Option<String>,
81 #[serde(default)]
83 pub asset_id: Option<String>,
84 #[serde(default)]
86 pub timestamp: Option<String>,
87 #[serde(default)]
89 pub hash: Option<String>,
90 #[serde(default)]
92 pub min_order_size: Option<String>,
93 #[serde(default)]
95 pub tick_size: Option<String>,
96 #[serde(default)]
98 pub neg_risk: Option<bool>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct PriceResponse {
104 pub price: String,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct MidpointResponse {
110 pub mid: String,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct PriceHistoryPoint {
116 pub t: i64,
118 pub p: f64,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct PriceHistoryResponse {
125 pub history: Vec<PriceHistoryPoint>,
126}
127
128#[derive(Debug, Clone, Copy)]
130pub enum PriceInterval {
131 OneMinute,
132 OneHour,
133 SixHours,
134 OneDay,
135 OneWeek,
136 Max,
137}
138
139impl PriceInterval {
140 pub fn as_str(&self) -> &'static str {
142 match self {
143 PriceInterval::OneMinute => "1m",
144 PriceInterval::OneHour => "1h",
145 PriceInterval::SixHours => "6h",
146 PriceInterval::OneDay => "1d",
147 PriceInterval::OneWeek => "1w",
148 PriceInterval::Max => "max",
149 }
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct SpreadRequest {
156 pub token_id: String,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 pub side: Option<Side>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct BatchTokenRequest {
164 pub token_id: String,
165 pub side: Side,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct TokenPrices {
171 #[serde(rename = "BUY", default)]
172 pub buy: Option<String>,
173 #[serde(rename = "SELL", default)]
174 pub sell: Option<String>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct Trade {
180 pub price: String,
181 pub size: String,
182 pub timestamp: i64,
183 pub side: String,
184 pub maker_order_id: Option<String>,
185 pub taker_order_id: Option<String>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct Order {
191 pub order_id: String,
192 pub market: String,
193 pub side: String,
194 #[serde(rename = "type")]
195 pub order_type: String,
196 pub price: Option<String>,
197 pub size: String,
198 pub filled: String,
199 pub status: String,
200 pub created_at: Option<i64>,
201 pub updated_at: Option<i64>,
202}
203
204pub struct ClobClient {
206 client: reqwest::Client,
207 api_key: Option<String>,
208 api_secret: Option<String>,
209 passphrase: Option<String>,
210 address: Option<String>,
212}
213
214impl ClobClient {
215 pub fn new() -> Self {
217 Self {
218 client: reqwest::Client::new(),
219 api_key: None,
220 api_secret: None,
221 passphrase: None,
222 address: None,
223 }
224 }
225
226 pub fn with_auth(
228 api_key: String,
229 api_secret: String,
230 passphrase: String,
231 address: String,
232 ) -> Self {
233 Self {
234 client: reqwest::Client::new(),
235 api_key: Some(api_key),
236 api_secret: Some(api_secret),
237 passphrase: Some(passphrase),
238 address: Some(address),
239 }
240 }
241
242 pub fn from_env() -> Self {
245 let address = std::env::var("address")
246 .or_else(|_| std::env::var("poly_address"))
247 .or_else(|_| std::env::var("POLY_ADDRESS"))
248 .ok();
249
250 if let (Ok(api_key), Ok(api_secret), Ok(passphrase), Some(addr)) = (
251 std::env::var("api_key"),
252 std::env::var("secret"),
253 std::env::var("passphrase"),
254 address,
255 ) {
256 Self::with_auth(api_key, api_secret, passphrase, addr)
257 } else {
258 Self::new()
259 }
260 }
261
262 pub fn has_auth(&self) -> bool {
264 self.api_key.is_some()
265 && self.api_secret.is_some()
266 && self.passphrase.is_some()
267 && self.address.is_some()
268 }
269
270 fn create_l2_headers(
272 &self,
273 method: &str,
274 request_path: &str,
275 body: Option<&str>,
276 ) -> Option<HeaderMap> {
277 if let (Some(api_key), Some(secret), Some(passphrase), Some(address)) = (
278 &self.api_key,
279 &self.api_secret,
280 &self.passphrase,
281 &self.address,
282 ) {
283 match L2Headers::new(
284 api_key,
285 secret,
286 passphrase,
287 address,
288 method,
289 request_path,
290 body,
291 ) {
292 Ok(headers) => Some(headers.to_header_map()),
293 Err(e) => {
294 log_debug!("Failed to create L2 headers: {}", e);
295 None
296 },
297 }
298 } else {
299 None
300 }
301 }
302
303 pub async fn get_orderbook(&self, condition_id: &str) -> Result<Orderbook> {
305 let url = format!("{}/book", CLOB_API_BASE);
306 let params = [("market", condition_id)];
307 let orderbook: Orderbook = self
308 .client
309 .get(&url)
310 .query(¶ms)
311 .send()
312 .await?
313 .json()
314 .await?;
315 Ok(orderbook)
316 }
317
318 pub async fn get_trades(&self, condition_id: &str, limit: Option<usize>) -> Result<Vec<Trade>> {
320 let url = format!("{}/trades", CLOB_API_BASE);
321 let mut params = vec![("market", condition_id.to_string())];
322 if let Some(limit) = limit {
323 params.push(("limit", limit.to_string()));
324 }
325 let trades: Vec<Trade> = self
326 .client
327 .get(&url)
328 .query(¶ms)
329 .send()
330 .await?
331 .json()
332 .await?;
333 Ok(trades)
334 }
335
336 pub async fn get_orderbook_by_asset(&self, token_id: &str) -> Result<Orderbook> {
338 let _url = format!("{}/book?token_id={}", CLOB_API_BASE, token_id);
339 log_info!("GET {}", _url);
340
341 let params = [("token_id", token_id)];
342 let response = self
343 .client
344 .get(format!("{}/book", CLOB_API_BASE))
345 .query(¶ms)
346 .send()
347 .await?;
348
349 let status = response.status();
350 log_info!("GET {} -> status: {}", _url, status);
351
352 if status == reqwest::StatusCode::NOT_FOUND {
354 log_info!(
355 "GET {} -> no orderbook (market may have no orders yet)",
356 _url
357 );
358 return Ok(Orderbook {
359 bids: Vec::new(),
360 asks: Vec::new(),
361 market: None,
362 asset_id: Some(token_id.to_string()),
363 timestamp: None,
364 hash: None,
365 min_order_size: None,
366 tick_size: None,
367 neg_risk: None,
368 });
369 }
370
371 if !status.is_success() {
372 let error_text = response
373 .text()
374 .await
375 .unwrap_or_else(|_| "Unknown error".to_string());
376 return Err(crate::error::PolymarketError::InvalidData(format!(
377 "HTTP {}: {}",
378 status, error_text
379 )));
380 }
381
382 let response_text = response.text().await?;
383 log_info!(
384 "GET {} -> bids/asks preview: {}",
385 _url,
386 if response_text.len() > 200 {
387 &response_text[..200]
388 } else {
389 &response_text
390 }
391 );
392
393 match serde_json::from_str::<Orderbook>(&response_text) {
395 Ok(orderbook) => {
396 log_info!(
397 "GET {} -> parsed: {} bids, {} asks",
398 _url,
399 orderbook.bids.len(),
400 orderbook.asks.len()
401 );
402 Ok(orderbook)
403 },
404 Err(_e) => {
405 log_debug!(
407 "Failed to parse orderbook response for token {}: {}. Response: {}",
408 token_id,
409 _e,
410 response_text
411 );
412 Ok(Orderbook {
414 bids: Vec::new(),
415 asks: Vec::new(),
416 market: None,
417 asset_id: Some(token_id.to_string()),
418 timestamp: None,
419 hash: None,
420 min_order_size: None,
421 tick_size: None,
422 neg_risk: None,
423 })
424 },
425 }
426 }
427
428 pub async fn get_trades_by_asset(
430 &self,
431 asset_id: &str,
432 limit: Option<usize>,
433 ) -> Result<Vec<Trade>> {
434 let url = format!("{}/trades", CLOB_API_BASE);
435 let mut params = vec![("asset_id", asset_id.to_string())];
436 if let Some(limit) = limit {
437 params.push(("limit", limit.to_string()));
438 }
439 let trades: Vec<Trade> = self
440 .client
441 .get(&url)
442 .query(¶ms)
443 .send()
444 .await?
445 .json()
446 .await?;
447 Ok(trades)
448 }
449
450 pub async fn get_trades_authenticated(
453 &self,
454 market: &str,
455 limit: Option<usize>,
456 ) -> Result<Vec<Trade>> {
457 let mut query_parts = vec![format!("market={}", market)];
459 if let Some(limit) = limit {
460 query_parts.push(format!("limit={}", limit));
461 }
462 let query_string = query_parts.join("&");
463 let request_path = format!("/trades?{}", query_string);
464
465 log_info!("GET {}{} (authenticated)", CLOB_API_BASE, request_path);
466
467 let headers = self
469 .create_l2_headers("GET", &request_path, None)
470 .ok_or_else(|| {
471 crate::error::PolymarketError::InvalidData(
472 "Missing authentication credentials".to_string(),
473 )
474 })?;
475
476 let url = format!("{}{}", CLOB_API_BASE, request_path);
477 let response = self.client.get(&url).headers(headers).send().await?;
478
479 let status = response.status();
480 if !status.is_success() {
481 let error_text = response
482 .text()
483 .await
484 .unwrap_or_else(|_| "Unknown error".to_string());
485 log_info!("GET {} -> error: {} - {}", request_path, status, error_text);
486 return Err(crate::error::PolymarketError::InvalidData(format!(
487 "HTTP {}: {}",
488 status, error_text
489 )));
490 }
491
492 let trades: Vec<Trade> = response.json().await?;
493 log_info!("GET {} -> {} trades", request_path, trades.len());
494 Ok(trades)
495 }
496
497 pub async fn get_trade_count(&self, market: &str) -> Result<usize> {
499 if self.has_auth() {
500 let trades = self.get_trades_authenticated(market, Some(1000)).await?;
502 Ok(trades.len())
503 } else {
504 Err(crate::error::PolymarketError::InvalidData(
506 "Authentication required to fetch trade counts".to_string(),
507 ))
508 }
509 }
510
511 pub async fn get_orders(&self) -> Result<Vec<Order>> {
513 let url = format!("{}/orders", CLOB_API_BASE);
514 let orders: Vec<Order> = self.client.get(&url).send().await?.json().await?;
516 Ok(orders)
517 }
518
519 pub async fn get_order(&self, order_id: &str) -> Result<Order> {
521 let url = format!("{}/orders/{}", CLOB_API_BASE, order_id);
522 let order: Order = self.client.get(&url).send().await?.json().await?;
524 Ok(order)
525 }
526
527 pub async fn place_order(
529 &self,
530 _market: &str,
531 _side: Side,
532 _order_type: OrderType,
533 _size: &str,
534 _price: Option<&str>,
535 ) -> Result<Order> {
536 let _url = format!("{}/orders", CLOB_API_BASE);
539 todo!("Order placement requires authentication signing")
540 }
541
542 pub async fn cancel_order(&self, order_id: &str) -> Result<()> {
544 let url = format!("{}/orders/{}", CLOB_API_BASE, order_id);
545 self.client.delete(&url).send().await?;
547 Ok(())
548 }
549
550 pub async fn get_price(&self, token_id: &str, side: Side) -> Result<PriceResponse> {
556 let url = format!("{}/price", CLOB_API_BASE);
557 let side_str = match side {
558 Side::Buy => "BUY",
559 Side::Sell => "SELL",
560 };
561 let params = [("token_id", token_id), ("side", side_str)];
562
563 let response = self.client.get(&url).query(¶ms).send().await?;
564
565 if !response.status().is_success() {
566 let error_text = response
567 .text()
568 .await
569 .unwrap_or_else(|_| "Unknown error".to_string());
570 return Err(crate::error::PolymarketError::InvalidData(error_text));
571 }
572
573 let price: PriceResponse = response.json().await?;
574 Ok(price)
575 }
576
577 pub async fn get_midpoint(&self, token_id: &str) -> Result<MidpointResponse> {
584 let url = format!("{}/midpoint", CLOB_API_BASE);
585 let params = [("token_id", token_id)];
586
587 let response = self.client.get(&url).query(¶ms).send().await?;
588
589 if !response.status().is_success() {
590 let error_text = response
591 .text()
592 .await
593 .unwrap_or_else(|_| "Unknown error".to_string());
594 return Err(crate::error::PolymarketError::InvalidData(error_text));
595 }
596
597 let midpoint: MidpointResponse = response.json().await?;
598 Ok(midpoint)
599 }
600
601 pub async fn get_prices_history(
610 &self,
611 token_id: &str,
612 start_ts: Option<i64>,
613 end_ts: Option<i64>,
614 interval: Option<PriceInterval>,
615 fidelity: Option<u32>,
616 ) -> Result<PriceHistoryResponse> {
617 let url = format!("{}/prices-history", CLOB_API_BASE);
618 let mut params = vec![("market", token_id.to_string())];
619
620 if let Some(start) = start_ts {
621 params.push(("startTs", start.to_string()));
622 }
623 if let Some(end) = end_ts {
624 params.push(("endTs", end.to_string()));
625 }
626 if let Some(interval) = interval {
627 params.push(("interval", interval.as_str().to_string()));
628 }
629 if let Some(fidelity) = fidelity {
630 params.push(("fidelity", fidelity.to_string()));
631 }
632
633 let response = self.client.get(&url).query(¶ms).send().await?;
634
635 if !response.status().is_success() {
636 let error_text = response
637 .text()
638 .await
639 .unwrap_or_else(|_| "Unknown error".to_string());
640 return Err(crate::error::PolymarketError::InvalidData(error_text));
641 }
642
643 let history: PriceHistoryResponse = response.json().await?;
644 Ok(history)
645 }
646
647 pub async fn get_spreads(
652 &self,
653 requests: Vec<SpreadRequest>,
654 ) -> Result<std::collections::HashMap<String, String>> {
655 let url = format!("{}/spreads", CLOB_API_BASE);
656
657 let response = self.client.post(&url).json(&requests).send().await?;
658
659 if !response.status().is_success() {
660 let error_text = response
661 .text()
662 .await
663 .unwrap_or_else(|_| "Unknown error".to_string());
664 return Err(crate::error::PolymarketError::InvalidData(error_text));
665 }
666
667 let spreads: std::collections::HashMap<String, String> = response.json().await?;
668 Ok(spreads)
669 }
670
671 pub async fn get_orderbooks(&self, requests: Vec<BatchTokenRequest>) -> Result<Vec<Orderbook>> {
676 let url = format!("{}/books", CLOB_API_BASE);
677
678 let response = self.client.post(&url).json(&requests).send().await?;
679
680 if !response.status().is_success() {
681 let error_text = response
682 .text()
683 .await
684 .unwrap_or_else(|_| "Unknown error".to_string());
685 return Err(crate::error::PolymarketError::InvalidData(error_text));
686 }
687
688 let orderbooks: Vec<Orderbook> = response.json().await?;
689 Ok(orderbooks)
690 }
691
692 pub async fn get_prices_batch(
700 &self,
701 requests: Vec<BatchTokenRequest>,
702 ) -> Result<std::collections::HashMap<String, TokenPrices>> {
703 let url = format!("{}/prices", CLOB_API_BASE);
704
705 let response = self.client.post(&url).json(&requests).send().await?;
706
707 if !response.status().is_success() {
708 let error_text = response
709 .text()
710 .await
711 .unwrap_or_else(|_| "Unknown error".to_string());
712 return Err(crate::error::PolymarketError::InvalidData(error_text));
713 }
714
715 let prices: std::collections::HashMap<String, TokenPrices> = response.json().await?;
716 Ok(prices)
717 }
718}
719
720impl Default for ClobClient {
721 fn default() -> Self {
722 Self::new()
723 }
724}
725
726type HmacSha256 = Hmac<Sha256>;
727
728fn build_hmac_signature(
733 secret: &str,
734 timestamp: i64,
735 method: &str,
736 request_path: &str,
737 body: Option<&str>,
738) -> std::result::Result<String, String> {
739 let secret_bytes = STANDARD
741 .decode(secret)
742 .map_err(|e| format!("Failed to decode secret: {}", e))?;
743
744 let mut message = format!("{}{}{}", timestamp, method, request_path);
746 if let Some(body) = body {
747 message.push_str(body);
748 }
749
750 let mut mac = HmacSha256::new_from_slice(&secret_bytes)
752 .map_err(|e| format!("Failed to create HMAC: {}", e))?;
753 mac.update(message.as_bytes());
754
755 let result = mac.finalize();
757 let signature = STANDARD.encode(result.into_bytes());
758
759 Ok(signature)
760}
761
762pub struct L2Headers {
764 pub api_key: String,
765 pub signature: String,
766 pub timestamp: i64,
767 pub passphrase: String,
768 pub address: String,
769}
770
771impl L2Headers {
772 pub fn new(
774 api_key: &str,
775 secret: &str,
776 passphrase: &str,
777 address: &str,
778 method: &str,
779 request_path: &str,
780 body: Option<&str>,
781 ) -> std::result::Result<Self, String> {
782 let timestamp = SystemTime::now()
783 .duration_since(UNIX_EPOCH)
784 .map_err(|e| format!("Failed to get timestamp: {}", e))?
785 .as_secs() as i64;
786
787 let signature = build_hmac_signature(secret, timestamp, method, request_path, body)?;
788
789 Ok(Self {
790 api_key: api_key.to_string(),
791 signature,
792 timestamp,
793 passphrase: passphrase.to_string(),
794 address: address.to_string(),
795 })
796 }
797
798 pub fn to_header_map(&self) -> HeaderMap {
801 let mut headers = HeaderMap::new();
802 headers.insert(
803 "POLY_ADDRESS",
804 HeaderValue::from_str(&self.address).unwrap_or_else(|_| HeaderValue::from_static("")),
805 );
806 headers.insert(
807 "POLY_API_KEY",
808 HeaderValue::from_str(&self.api_key).unwrap_or_else(|_| HeaderValue::from_static("")),
809 );
810 headers.insert(
811 "POLY_SIGNATURE",
812 HeaderValue::from_str(&self.signature).unwrap_or_else(|_| HeaderValue::from_static("")),
813 );
814 headers.insert(
815 "POLY_TIMESTAMP",
816 HeaderValue::from_str(&self.timestamp.to_string())
817 .unwrap_or_else(|_| HeaderValue::from_static("")),
818 );
819 headers.insert(
820 "POLY_PASSPHRASE",
821 HeaderValue::from_str(&self.passphrase)
822 .unwrap_or_else(|_| HeaderValue::from_static("")),
823 );
824 headers
825 }
826}