use {
crate::error::Result,
base64::{Engine, engine::general_purpose::STANDARD},
hmac::{Hmac, Mac},
reqwest::header::{HeaderMap, HeaderValue},
serde::{Deserialize, Serialize},
sha2::Sha256,
std::time::{SystemTime, UNIX_EPOCH},
};
#[cfg(feature = "tracing")]
macro_rules! log_info {
($($arg:tt)*) => { tracing::info!($($arg)*) };
}
#[cfg(not(feature = "tracing"))]
macro_rules! log_info {
($($arg:tt)*) => {};
}
#[cfg(feature = "tracing")]
macro_rules! log_debug {
($($arg:tt)*) => { tracing::debug!($($arg)*) };
}
#[cfg(not(feature = "tracing"))]
macro_rules! log_debug {
($($arg:tt)*) => {};
}
const CLOB_API_BASE: &str = "https://clob.polymarket.com";
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Side {
Buy,
Sell,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum OrderType {
Limit,
Market,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OrderStatus {
Open,
Filled,
Cancelled,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceLevel {
pub price: String,
pub size: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Orderbook {
pub bids: Vec<PriceLevel>,
pub asks: Vec<PriceLevel>,
#[serde(default)]
pub market: Option<String>,
#[serde(default)]
pub asset_id: Option<String>,
#[serde(default)]
pub timestamp: Option<String>,
#[serde(default)]
pub hash: Option<String>,
#[serde(default)]
pub min_order_size: Option<String>,
#[serde(default)]
pub tick_size: Option<String>,
#[serde(default)]
pub neg_risk: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceResponse {
pub price: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MidpointResponse {
pub mid: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceHistoryPoint {
pub t: i64,
pub p: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceHistoryResponse {
pub history: Vec<PriceHistoryPoint>,
}
#[derive(Debug, Clone, Copy)]
pub enum PriceInterval {
OneMinute,
OneHour,
SixHours,
OneDay,
OneWeek,
Max,
}
impl PriceInterval {
pub fn as_str(&self) -> &'static str {
match self {
PriceInterval::OneMinute => "1m",
PriceInterval::OneHour => "1h",
PriceInterval::SixHours => "6h",
PriceInterval::OneDay => "1d",
PriceInterval::OneWeek => "1w",
PriceInterval::Max => "max",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpreadRequest {
pub token_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub side: Option<Side>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchTokenRequest {
pub token_id: String,
pub side: Side,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenPrices {
#[serde(rename = "BUY", default)]
pub buy: Option<String>,
#[serde(rename = "SELL", default)]
pub sell: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trade {
pub price: String,
pub size: String,
pub timestamp: i64,
pub side: String,
pub maker_order_id: Option<String>,
pub taker_order_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Order {
pub order_id: String,
pub market: String,
pub side: String,
#[serde(rename = "type")]
pub order_type: String,
pub price: Option<String>,
pub size: String,
pub filled: String,
pub status: String,
pub created_at: Option<i64>,
pub updated_at: Option<i64>,
}
pub struct ClobClient {
client: reqwest::Client,
api_key: Option<String>,
api_secret: Option<String>,
passphrase: Option<String>,
address: Option<String>,
}
impl ClobClient {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
api_key: None,
api_secret: None,
passphrase: None,
address: None,
}
}
pub fn with_auth(
api_key: String,
api_secret: String,
passphrase: String,
address: String,
) -> Self {
Self {
client: reqwest::Client::new(),
api_key: Some(api_key),
api_secret: Some(api_secret),
passphrase: Some(passphrase),
address: Some(address),
}
}
pub fn from_env() -> Self {
let address = std::env::var("address")
.or_else(|_| std::env::var("poly_address"))
.or_else(|_| std::env::var("POLY_ADDRESS"))
.ok();
if let (Ok(api_key), Ok(api_secret), Ok(passphrase), Some(addr)) = (
std::env::var("api_key"),
std::env::var("secret"),
std::env::var("passphrase"),
address,
) {
Self::with_auth(api_key, api_secret, passphrase, addr)
} else {
Self::new()
}
}
pub fn has_auth(&self) -> bool {
self.api_key.is_some()
&& self.api_secret.is_some()
&& self.passphrase.is_some()
&& self.address.is_some()
}
fn create_l2_headers(
&self,
method: &str,
request_path: &str,
body: Option<&str>,
) -> Option<HeaderMap> {
if let (Some(api_key), Some(secret), Some(passphrase), Some(address)) = (
&self.api_key,
&self.api_secret,
&self.passphrase,
&self.address,
) {
match L2Headers::new(
api_key,
secret,
passphrase,
address,
method,
request_path,
body,
) {
Ok(headers) => Some(headers.to_header_map()),
Err(e) => {
log_debug!("Failed to create L2 headers: {}", e);
None
},
}
} else {
None
}
}
pub async fn get_orderbook(&self, condition_id: &str) -> Result<Orderbook> {
let url = format!("{}/book", CLOB_API_BASE);
let params = [("market", condition_id)];
let orderbook: Orderbook = self
.client
.get(&url)
.query(¶ms)
.send()
.await?
.json()
.await?;
Ok(orderbook)
}
pub async fn get_trades(&self, condition_id: &str, limit: Option<usize>) -> Result<Vec<Trade>> {
let url = format!("{}/trades", CLOB_API_BASE);
let mut params = vec![("market", condition_id.to_string())];
if let Some(limit) = limit {
params.push(("limit", limit.to_string()));
}
let trades: Vec<Trade> = self
.client
.get(&url)
.query(¶ms)
.send()
.await?
.json()
.await?;
Ok(trades)
}
pub async fn get_orderbook_by_asset(&self, token_id: &str) -> Result<Orderbook> {
let _url = format!("{}/book?token_id={}", CLOB_API_BASE, token_id);
log_info!("GET {}", _url);
let params = [("token_id", token_id)];
let response = self
.client
.get(format!("{}/book", CLOB_API_BASE))
.query(¶ms)
.send()
.await?;
let status = response.status();
log_info!("GET {} -> status: {}", _url, status);
if status == reqwest::StatusCode::NOT_FOUND {
log_info!(
"GET {} -> no orderbook (market may have no orders yet)",
_url
);
return Ok(Orderbook {
bids: Vec::new(),
asks: Vec::new(),
market: None,
asset_id: Some(token_id.to_string()),
timestamp: None,
hash: None,
min_order_size: None,
tick_size: None,
neg_risk: None,
});
}
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(crate::error::PolymarketError::InvalidData(format!(
"HTTP {}: {}",
status, error_text
)));
}
let response_text = response.text().await?;
log_info!(
"GET {} -> bids/asks preview: {}",
_url,
if response_text.len() > 200 {
&response_text[..200]
} else {
&response_text
}
);
match serde_json::from_str::<Orderbook>(&response_text) {
Ok(orderbook) => {
log_info!(
"GET {} -> parsed: {} bids, {} asks",
_url,
orderbook.bids.len(),
orderbook.asks.len()
);
Ok(orderbook)
},
Err(_e) => {
log_debug!(
"Failed to parse orderbook response for token {}: {}. Response: {}",
token_id,
_e,
response_text
);
Ok(Orderbook {
bids: Vec::new(),
asks: Vec::new(),
market: None,
asset_id: Some(token_id.to_string()),
timestamp: None,
hash: None,
min_order_size: None,
tick_size: None,
neg_risk: None,
})
},
}
}
pub async fn get_trades_by_asset(
&self,
asset_id: &str,
limit: Option<usize>,
) -> Result<Vec<Trade>> {
let url = format!("{}/trades", CLOB_API_BASE);
let mut params = vec![("asset_id", asset_id.to_string())];
if let Some(limit) = limit {
params.push(("limit", limit.to_string()));
}
let trades: Vec<Trade> = self
.client
.get(&url)
.query(¶ms)
.send()
.await?
.json()
.await?;
Ok(trades)
}
pub async fn get_trades_authenticated(
&self,
market: &str,
limit: Option<usize>,
) -> Result<Vec<Trade>> {
let mut query_parts = vec![format!("market={}", market)];
if let Some(limit) = limit {
query_parts.push(format!("limit={}", limit));
}
let query_string = query_parts.join("&");
let request_path = format!("/trades?{}", query_string);
log_info!("GET {}{} (authenticated)", CLOB_API_BASE, request_path);
let headers = self
.create_l2_headers("GET", &request_path, None)
.ok_or_else(|| {
crate::error::PolymarketError::InvalidData(
"Missing authentication credentials".to_string(),
)
})?;
let url = format!("{}{}", CLOB_API_BASE, request_path);
let response = self.client.get(&url).headers(headers).send().await?;
let status = response.status();
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log_info!("GET {} -> error: {} - {}", request_path, status, error_text);
return Err(crate::error::PolymarketError::InvalidData(format!(
"HTTP {}: {}",
status, error_text
)));
}
let trades: Vec<Trade> = response.json().await?;
log_info!("GET {} -> {} trades", request_path, trades.len());
Ok(trades)
}
pub async fn get_trade_count(&self, market: &str) -> Result<usize> {
if self.has_auth() {
let trades = self.get_trades_authenticated(market, Some(1000)).await?;
Ok(trades.len())
} else {
Err(crate::error::PolymarketError::InvalidData(
"Authentication required to fetch trade counts".to_string(),
))
}
}
pub async fn get_orders(&self) -> Result<Vec<Order>> {
let url = format!("{}/orders", CLOB_API_BASE);
let orders: Vec<Order> = self.client.get(&url).send().await?.json().await?;
Ok(orders)
}
pub async fn get_order(&self, order_id: &str) -> Result<Order> {
let url = format!("{}/orders/{}", CLOB_API_BASE, order_id);
let order: Order = self.client.get(&url).send().await?.json().await?;
Ok(order)
}
pub async fn place_order(
&self,
_market: &str,
_side: Side,
_order_type: OrderType,
_size: &str,
_price: Option<&str>,
) -> Result<Order> {
let _url = format!("{}/orders", CLOB_API_BASE);
todo!("Order placement requires authentication signing")
}
pub async fn cancel_order(&self, order_id: &str) -> Result<()> {
let url = format!("{}/orders/{}", CLOB_API_BASE, order_id);
self.client.delete(&url).send().await?;
Ok(())
}
pub async fn get_price(&self, token_id: &str, side: Side) -> Result<PriceResponse> {
let url = format!("{}/price", CLOB_API_BASE);
let side_str = match side {
Side::Buy => "BUY",
Side::Sell => "SELL",
};
let params = [("token_id", token_id), ("side", side_str)];
let response = self.client.get(&url).query(¶ms).send().await?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(crate::error::PolymarketError::InvalidData(error_text));
}
let price: PriceResponse = response.json().await?;
Ok(price)
}
pub async fn get_midpoint(&self, token_id: &str) -> Result<MidpointResponse> {
let url = format!("{}/midpoint", CLOB_API_BASE);
let params = [("token_id", token_id)];
let response = self.client.get(&url).query(¶ms).send().await?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(crate::error::PolymarketError::InvalidData(error_text));
}
let midpoint: MidpointResponse = response.json().await?;
Ok(midpoint)
}
pub async fn get_prices_history(
&self,
token_id: &str,
start_ts: Option<i64>,
end_ts: Option<i64>,
interval: Option<PriceInterval>,
fidelity: Option<u32>,
) -> Result<PriceHistoryResponse> {
let url = format!("{}/prices-history", CLOB_API_BASE);
let mut params = vec![("market", token_id.to_string())];
if let Some(start) = start_ts {
params.push(("startTs", start.to_string()));
}
if let Some(end) = end_ts {
params.push(("endTs", end.to_string()));
}
if let Some(interval) = interval {
params.push(("interval", interval.as_str().to_string()));
}
if let Some(fidelity) = fidelity {
params.push(("fidelity", fidelity.to_string()));
}
let response = self.client.get(&url).query(¶ms).send().await?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(crate::error::PolymarketError::InvalidData(error_text));
}
let history: PriceHistoryResponse = response.json().await?;
Ok(history)
}
pub async fn get_spreads(
&self,
requests: Vec<SpreadRequest>,
) -> Result<std::collections::HashMap<String, String>> {
let url = format!("{}/spreads", CLOB_API_BASE);
let response = self.client.post(&url).json(&requests).send().await?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(crate::error::PolymarketError::InvalidData(error_text));
}
let spreads: std::collections::HashMap<String, String> = response.json().await?;
Ok(spreads)
}
pub async fn get_orderbooks(&self, requests: Vec<BatchTokenRequest>) -> Result<Vec<Orderbook>> {
let url = format!("{}/books", CLOB_API_BASE);
let response = self.client.post(&url).json(&requests).send().await?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(crate::error::PolymarketError::InvalidData(error_text));
}
let orderbooks: Vec<Orderbook> = response.json().await?;
Ok(orderbooks)
}
pub async fn get_prices_batch(
&self,
requests: Vec<BatchTokenRequest>,
) -> Result<std::collections::HashMap<String, TokenPrices>> {
let url = format!("{}/prices", CLOB_API_BASE);
let response = self.client.post(&url).json(&requests).send().await?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(crate::error::PolymarketError::InvalidData(error_text));
}
let prices: std::collections::HashMap<String, TokenPrices> = response.json().await?;
Ok(prices)
}
}
impl Default for ClobClient {
fn default() -> Self {
Self::new()
}
}
type HmacSha256 = Hmac<Sha256>;
fn build_hmac_signature(
secret: &str,
timestamp: i64,
method: &str,
request_path: &str,
body: Option<&str>,
) -> std::result::Result<String, String> {
let secret_bytes = STANDARD
.decode(secret)
.map_err(|e| format!("Failed to decode secret: {}", e))?;
let mut message = format!("{}{}{}", timestamp, method, request_path);
if let Some(body) = body {
message.push_str(body);
}
let mut mac = HmacSha256::new_from_slice(&secret_bytes)
.map_err(|e| format!("Failed to create HMAC: {}", e))?;
mac.update(message.as_bytes());
let result = mac.finalize();
let signature = STANDARD.encode(result.into_bytes());
Ok(signature)
}
pub struct L2Headers {
pub api_key: String,
pub signature: String,
pub timestamp: i64,
pub passphrase: String,
pub address: String,
}
impl L2Headers {
pub fn new(
api_key: &str,
secret: &str,
passphrase: &str,
address: &str,
method: &str,
request_path: &str,
body: Option<&str>,
) -> std::result::Result<Self, String> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| format!("Failed to get timestamp: {}", e))?
.as_secs() as i64;
let signature = build_hmac_signature(secret, timestamp, method, request_path, body)?;
Ok(Self {
api_key: api_key.to_string(),
signature,
timestamp,
passphrase: passphrase.to_string(),
address: address.to_string(),
})
}
pub fn to_header_map(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
"POLY_ADDRESS",
HeaderValue::from_str(&self.address).unwrap_or_else(|_| HeaderValue::from_static("")),
);
headers.insert(
"POLY_API_KEY",
HeaderValue::from_str(&self.api_key).unwrap_or_else(|_| HeaderValue::from_static("")),
);
headers.insert(
"POLY_SIGNATURE",
HeaderValue::from_str(&self.signature).unwrap_or_else(|_| HeaderValue::from_static("")),
);
headers.insert(
"POLY_TIMESTAMP",
HeaderValue::from_str(&self.timestamp.to_string())
.unwrap_or_else(|_| HeaderValue::from_static("")),
);
headers.insert(
"POLY_PASSPHRASE",
HeaderValue::from_str(&self.passphrase)
.unwrap_or_else(|_| HeaderValue::from_static("")),
);
headers
}
}