use std::collections::HashMap;
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::Duration;
use alloy_primitives::U256;
use alloy_signer_local::PrivateKeySigner;
use governor::{Quota, RateLimiter as GovRateLimiter};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::{debug, info, instrument, warn};
use crate::auth::{
create_l1_headers, create_l2_headers, create_l2_headers_with_body_string,
get_current_unix_time_secs,
};
use crate::core::clob_api_url;
use crate::core::{PolymarketError, Result};
use crate::types::{ApiCredentials, SignedOrderRequest};
use crate::auth::{BuilderApiKeyCreds, BuilderSigner};
type RateLimiter = GovRateLimiter<
governor::state::NotKeyed,
governor::state::InMemoryState,
governor::clock::DefaultClock,
>;
#[derive(Debug, Clone)]
pub struct ClobConfig {
pub base_url: String,
pub timeout: Duration,
pub rate_limit_per_second: u32,
pub user_agent: String,
}
impl Default for ClobConfig {
fn default() -> Self {
Self {
base_url: clob_api_url(),
timeout: Duration::from_secs(30),
rate_limit_per_second: 5,
user_agent: "polymarket-sdk/0.1.0".to_string(),
}
}
}
impl ClobConfig {
#[must_use]
pub fn builder() -> Self {
Self::default()
}
#[must_use]
#[deprecated(
since = "0.1.0",
note = "Use ClobConfig::default() instead. URL override via POLYMARKET_CLOB_URL env var is already supported."
)]
pub fn from_env() -> Self {
Self::default()
}
#[must_use]
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn with_rate_limit(mut self, rate_limit: u32) -> Self {
self.rate_limit_per_second = rate_limit;
self
}
#[must_use]
pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
}
#[derive(Debug, Deserialize)]
pub struct DeriveApiKeyResponse {
#[serde(rename = "apiKey")]
pub api_key: String,
pub secret: String,
pub passphrase: String,
}
#[derive(Debug, Deserialize)]
pub struct ApiKeyResponse {
#[serde(rename = "apiKey")]
pub api_key: String,
pub secret: String,
pub passphrase: String,
}
#[derive(Debug, Serialize)]
struct CreateApiKeyRequest {
nonce: String,
}
#[derive(Debug, Deserialize)]
pub struct OrderResponse {
pub success: bool,
#[serde(rename = "errorMsg")]
pub error_msg: String,
#[serde(rename = "orderID")]
pub order_id: String,
#[serde(rename = "transactionsHashes", default)]
pub transactions_hashes: Vec<String>,
pub status: String,
}
#[derive(Debug, Deserialize)]
pub struct PaginatedResponse<T> {
pub limit: Option<i32>,
pub count: Option<i32>,
pub next_cursor: String,
pub data: Vec<T>,
}
#[derive(Debug, Deserialize)]
pub struct OpenOrder {
pub id: String,
pub status: String,
#[serde(default)]
pub owner: Option<String>,
pub maker_address: String,
#[serde(default)]
pub market: Option<String>,
pub asset_id: String,
pub side: String,
pub original_size: String,
pub size_matched: String,
pub price: String,
#[serde(default)]
pub associate_trades: Option<Vec<String>>,
#[serde(default)]
pub outcome: Option<String>,
pub created_at: Option<i64>,
#[serde(default)]
pub expiration: Option<String>,
#[serde(default)]
pub order_type: Option<String>,
}
impl OpenOrder {
#[must_use]
pub fn token_id(&self) -> &str {
&self.asset_id
}
#[must_use]
pub fn maker(&self) -> &str {
&self.maker_address
}
#[must_use]
pub fn signer(&self) -> &str {
&self.maker_address
}
}
#[derive(Debug, Serialize)]
struct CancelOrdersRequest {
#[serde(rename = "orderIds")]
order_ids: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct CancelResponse {
pub canceled: Vec<String>,
pub failed: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
pub struct NegRiskResponse {
pub neg_risk: bool,
}
#[derive(Debug, Deserialize)]
pub struct TickSizeResponse {
pub minimum_tick_size: String,
}
#[derive(Debug, Deserialize)]
pub struct FeeRateResponse {
pub base_fee: u32,
}
#[derive(Clone)]
pub struct ClobClient {
config: ClobConfig,
client: Client,
signer: PrivateKeySigner,
rate_limiter: Arc<RateLimiter>,
api_credentials: Option<ApiCredentials>,
auth_address: Option<String>,
builder_signer: Option<BuilderSigner>,
}
impl ClobClient {
pub fn new(config: ClobConfig, signer: PrivateKeySigner) -> Result<Self> {
let client = Client::builder()
.timeout(config.timeout)
.user_agent(&config.user_agent)
.gzip(true)
.build()
.map_err(|e| PolymarketError::config(format!("Failed to create HTTP client: {e}")))?;
let quota = Quota::per_second(
NonZeroU32::new(config.rate_limit_per_second).unwrap_or(NonZeroU32::new(5).unwrap()),
);
let rate_limiter = Arc::new(GovRateLimiter::direct(quota));
Ok(Self {
config,
client,
signer,
rate_limiter,
api_credentials: None,
auth_address: None,
builder_signer: None,
})
}
#[deprecated(
since = "0.1.0",
note = "Use ClobClient::new(ClobConfig::default(), signer) instead"
)]
#[allow(deprecated)]
pub fn from_env(signer: PrivateKeySigner) -> Result<Self> {
Self::new(ClobConfig::from_env(), signer)
}
#[must_use]
pub fn with_api_credentials(mut self, credentials: ApiCredentials) -> Self {
self.api_credentials = Some(credentials);
self
}
#[must_use]
pub fn with_auth_address(mut self, address: impl Into<String>) -> Self {
self.auth_address = Some(address.into());
self
}
#[must_use]
pub fn with_builder_signer(mut self, credentials: ApiCredentials) -> Self {
let builder_creds = BuilderApiKeyCreds {
key: credentials.api_key,
secret: credentials.secret, passphrase: credentials.passphrase,
};
self.builder_signer = Some(BuilderSigner::new(builder_creds));
self
}
#[must_use]
pub fn address(&self) -> String {
format!("{:?}", self.signer.address())
}
async fn wait_for_rate_limit(&self) {
self.rate_limiter.until_ready().await;
}
#[instrument(skip(self))]
pub async fn derive_api_key(&self, nonce: Option<U256>) -> Result<DeriveApiKeyResponse> {
self.wait_for_rate_limit().await;
let endpoint = "/auth/derive-api-key";
let url = format!("{}{}", self.config.base_url, endpoint);
let headers = create_l1_headers(&self.signer, nonce)?;
debug!(address = %self.address(), "Deriving API key");
let mut req_builder = self.client.get(&url);
for (key, value) in &headers {
req_builder = req_builder.header(*key, value);
}
let response = req_builder.send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(PolymarketError::api(status.as_u16(), body));
}
let result: DeriveApiKeyResponse = response.json().await.map_err(|e| {
PolymarketError::parse_with_source(format!("Failed to parse derive response: {e}"), e)
})?;
info!(address = %self.address(), "API key derived successfully");
Ok(result)
}
#[instrument(skip(self, signature))]
pub async fn derive_api_key_with_signature(
&self,
address: &str,
signature: &str,
timestamp: &str,
nonce: U256,
) -> Result<DeriveApiKeyResponse> {
self.wait_for_rate_limit().await;
let endpoint = "/auth/derive-api-key";
let url = format!("{}{}", self.config.base_url, endpoint);
let address = if address.starts_with("0x") {
address.to_string()
} else {
format!("0x{}", address)
};
let signature = if signature.starts_with("0x") {
signature.to_string()
} else {
format!("0x{}", signature)
};
let headers = HashMap::from([
("poly_address", address.clone()),
("poly_signature", signature),
("poly_timestamp", timestamp.to_string()),
("poly_nonce", nonce.to_string()),
]);
debug!(address = %address, "Deriving API key with pre-computed signature");
let mut req_builder = self.client.get(&url);
for (key, value) in &headers {
req_builder = req_builder.header(*key, value);
}
let response = req_builder.send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(PolymarketError::api(status.as_u16(), body));
}
let result: DeriveApiKeyResponse = response.json().await.map_err(|e| {
PolymarketError::parse_with_source(format!("Failed to parse derive response: {e}"), e)
})?;
info!(address = %address, "API key derived successfully with pre-computed signature");
Ok(result)
}
#[instrument(skip(self, signature))]
pub async fn create_api_key_with_signature(
&self,
address: &str,
signature: &str,
timestamp: &str,
nonce: U256,
) -> Result<ApiKeyResponse> {
self.wait_for_rate_limit().await;
let endpoint = "/auth/api-key";
let url = format!("{}{}", self.config.base_url, endpoint);
let address = if address.starts_with("0x") {
address.to_string()
} else {
format!("0x{}", address)
};
let signature = if signature.starts_with("0x") {
signature.to_string()
} else {
format!("0x{}", signature)
};
let headers = HashMap::from([
("poly_address", address.clone()),
("poly_signature", signature),
("poly_timestamp", timestamp.to_string()),
("poly_nonce", nonce.to_string()),
]);
let body = CreateApiKeyRequest {
nonce: nonce.to_string(),
};
debug!(address = %address, "Creating API key with pre-computed signature (first-time registration)");
let mut req_builder = self.client.post(&url).json(&body);
for (key, value) in &headers {
req_builder = req_builder.header(*key, value);
}
let response = req_builder.send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(PolymarketError::api(status.as_u16(), body));
}
let result: ApiKeyResponse = response.json().await.map_err(|e| {
PolymarketError::parse_with_source(format!("Failed to parse create response: {e}"), e)
})?;
info!(address = %address, "API key created successfully (wallet registered)");
Ok(result)
}
#[instrument(skip(self, signature))]
pub async fn derive_or_create_api_key(
&self,
address: &str,
signature: &str,
timestamp: &str,
nonce: U256,
) -> Result<DeriveApiKeyResponse> {
match self
.derive_api_key_with_signature(address, signature, timestamp, nonce)
.await
{
Ok(response) => Ok(response),
Err(e) if e.is_wallet_not_registered() => {
info!(
address = %address,
"Wallet not registered with CLOB API, registering first"
);
self.create_api_key_with_signature(address, signature, timestamp, nonce)
.await?;
info!(address = %address, "Wallet registered, retrying derive");
self.derive_api_key_with_signature(address, signature, timestamp, nonce)
.await
}
Err(e) => Err(e),
}
}
#[instrument(skip(self))]
pub async fn create_api_key(&self, nonce: U256) -> Result<ApiKeyResponse> {
self.wait_for_rate_limit().await;
let endpoint = "/auth/api-key";
let url = format!("{}{}", self.config.base_url, endpoint);
let headers = create_l1_headers(&self.signer, Some(nonce))?;
let body = CreateApiKeyRequest {
nonce: nonce.to_string(),
};
debug!(address = %self.address(), "Creating API key");
let mut req_builder = self.client.post(&url).json(&body);
for (key, value) in &headers {
req_builder = req_builder.header(*key, value);
}
let response = req_builder.send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(PolymarketError::api(status.as_u16(), body));
}
let result: ApiKeyResponse = response.json().await.map_err(|e| {
PolymarketError::parse_with_source(format!("Failed to parse create response: {e}"), e)
})?;
info!(address = %self.address(), "API key created successfully");
Ok(result)
}
#[instrument(skip(self))]
pub async fn create_api_credentials(&self) -> Result<ApiCredentials> {
info!(address = %self.address(), "Creating API credentials");
let _derive_result = self.derive_api_key(None).await?;
let create_result = self.create_api_key(U256::ZERO).await?;
let credentials = ApiCredentials {
api_key: create_result.api_key,
secret: create_result.secret,
passphrase: create_result.passphrase,
};
info!(address = %self.address(), "API credentials created successfully");
Ok(credentials)
}
#[instrument(skip(self, order))]
pub async fn submit_order(&self, order: &SignedOrderRequest) -> Result<OrderResponse> {
use crate::types::{NewOrder, OrderType};
self.wait_for_rate_limit().await;
let api_creds = self.api_credentials.as_ref().ok_or_else(|| {
PolymarketError::config("API credentials required for order submission")
})?;
let endpoint = "/order";
let url = format!("{}{}", self.config.base_url, endpoint);
let new_order =
NewOrder::from_signed_order(order, &api_creds.api_key, OrderType::GTC, false);
let body_str = serde_json::to_string(&new_order)
.map_err(|e| PolymarketError::parse(format!("Failed to serialize order: {}", e)))?;
let timestamp = get_current_unix_time_secs();
let address = if let Some(ref addr) = self.auth_address {
if addr.starts_with("0x") {
addr.clone()
} else {
format!("0x{}", addr)
}
} else {
format!("{:?}", self.signer.address())
};
let mut headers = create_l2_headers_with_body_string(
&address, api_creds, "POST", endpoint, &body_str, timestamp,
)?;
if let Some(ref builder) = self.builder_signer {
info!("Builder signer configured, generating Builder headers");
let builder_headers = builder
.create_builder_header_payload(
"POST",
endpoint,
Some(&body_str),
Some(timestamp as i64),
)
.map_err(|e| PolymarketError::internal(format!("Builder header error: {}", e)))?;
info!(
header_count = builder_headers.len(),
timestamp = timestamp,
"Builder headers generated with shared timestamp"
);
for (key, value) in builder_headers {
let static_key: &'static str = Box::leak(key.into_boxed_str());
headers.insert(static_key, value);
}
info!(token_id = %order.token_id, side = %order.side, "Submitting order with Builder authentication");
} else {
warn!("Builder signer NOT configured - order may fail with 401 Unauthorized");
info!(token_id = %order.token_id, side = %order.side, "Submitting order WITHOUT Builder authentication");
}
info!(order_json = %body_str, timestamp = timestamp, "Order JSON payload being sent to Polymarket (NewOrder format)");
let mut req_builder = self
.client
.post(&url)
.header("Content-Type", "application/json")
.body(body_str.clone());
for (key, value) in &headers {
req_builder = req_builder.header(*key, value);
}
let response = req_builder.send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(PolymarketError::api(status.as_u16(), body));
}
let result: OrderResponse = response.json().await.map_err(|e| {
PolymarketError::parse_with_source(format!("Failed to parse order response: {e}"), e)
})?;
if !result.success {
return Err(PolymarketError::api(
400,
format!(
"Order rejected: {} (status: {})",
result.error_msg, result.status
),
));
}
info!(
order_id = %result.order_id,
status = %result.status,
success = result.success,
"Order submitted successfully"
);
Ok(result)
}
#[instrument(skip(self))]
pub async fn get_open_orders(&self) -> Result<Vec<OpenOrder>> {
self.wait_for_rate_limit().await;
let api_creds = self.api_credentials.as_ref().ok_or_else(|| {
PolymarketError::config("API credentials required for querying orders")
})?;
let endpoint = "/data/orders";
let url = format!("{}{}", self.config.base_url, endpoint);
let headers = create_l2_headers::<String>(&self.signer, api_creds, "GET", endpoint, None)?;
debug!(address = %self.address(), "Getting open orders");
let mut req_builder = self.client.get(&url);
for (key, value) in &headers {
req_builder = req_builder.header(*key, value);
}
let response = req_builder.send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(PolymarketError::api(status.as_u16(), body));
}
let paginated: PaginatedResponse<OpenOrder> = response.json().await.map_err(|e| {
PolymarketError::parse_with_source(format!("Failed to parse orders response: {e}"), e)
})?;
debug!(count = %paginated.data.len(), "Retrieved open orders");
Ok(paginated.data)
}
#[instrument(skip(self))]
pub async fn cancel_orders(&self, order_ids: Vec<String>) -> Result<CancelResponse> {
self.wait_for_rate_limit().await;
let api_creds = self.api_credentials.as_ref().ok_or_else(|| {
PolymarketError::config("API credentials required for cancelling orders")
})?;
let endpoint = "/order";
let url = format!("{}{}", self.config.base_url, endpoint);
let body = CancelOrdersRequest { order_ids };
let headers = create_l2_headers(&self.signer, api_creds, "DELETE", endpoint, Some(&body))?;
debug!("Cancelling orders");
let mut req_builder = self.client.delete(&url).json(&body);
for (key, value) in &headers {
req_builder = req_builder.header(*key, value);
}
let response = req_builder.send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(PolymarketError::api(status.as_u16(), body));
}
let result: CancelResponse = response.json().await.map_err(|e| {
PolymarketError::parse_with_source(format!("Failed to parse cancel response: {e}"), e)
})?;
info!(cancelled = ?result.canceled, "Orders cancelled");
Ok(result)
}
#[instrument(skip(self))]
pub async fn cancel_all_orders(&self) -> Result<CancelResponse> {
self.wait_for_rate_limit().await;
let api_creds = self.api_credentials.as_ref().ok_or_else(|| {
PolymarketError::config("API credentials required for cancelling orders")
})?;
let endpoint = "/order/cancel-all";
let url = format!("{}{}", self.config.base_url, endpoint);
let headers =
create_l2_headers::<String>(&self.signer, api_creds, "DELETE", endpoint, None)?;
debug!(address = %self.address(), "Cancelling all orders");
let mut req_builder = self.client.delete(&url);
for (key, value) in &headers {
req_builder = req_builder.header(*key, value);
}
let response = req_builder.send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(PolymarketError::api(status.as_u16(), body));
}
let result: CancelResponse = response.json().await.map_err(|e| {
PolymarketError::parse_with_source(format!("Failed to parse cancel response: {e}"), e)
})?;
info!(cancelled = ?result.canceled, "All orders cancelled");
Ok(result)
}
#[instrument(skip(self))]
pub async fn get_neg_risk(&self, token_id: &str) -> Result<bool> {
self.wait_for_rate_limit().await;
let endpoint = "/neg-risk";
let url = format!("{}{}?token_id={}", self.config.base_url, endpoint, token_id);
debug!(token_id = %token_id, "Querying neg_risk status");
let response = self.client.get(&url).send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(PolymarketError::api(status.as_u16(), body));
}
let result: NegRiskResponse = response.json().await.map_err(|e| {
PolymarketError::parse_with_source(format!("Failed to parse neg_risk response: {e}"), e)
})?;
debug!(token_id = %token_id, neg_risk = %result.neg_risk, "Got neg_risk status");
Ok(result.neg_risk)
}
#[instrument(skip(self))]
pub async fn get_tick_size(&self, token_id: &str) -> Result<String> {
self.wait_for_rate_limit().await;
let endpoint = "/tick-size";
let url = format!("{}{}?token_id={}", self.config.base_url, endpoint, token_id);
debug!(token_id = %token_id, "Querying tick size");
let response = self.client.get(&url).send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(PolymarketError::api(status.as_u16(), body));
}
let result: TickSizeResponse = response.json().await.map_err(|e| {
PolymarketError::parse_with_source(
format!("Failed to parse tick_size response: {e}"),
e,
)
})?;
debug!(token_id = %token_id, tick_size = %result.minimum_tick_size, "Got tick size");
Ok(result.minimum_tick_size)
}
#[instrument(skip(self))]
pub async fn get_fee_rate(&self, token_id: &str) -> Result<u32> {
self.wait_for_rate_limit().await;
let endpoint = "/fee-rate";
let url = format!("{}{}?token_id={}", self.config.base_url, endpoint, token_id);
debug!(token_id = %token_id, "Querying fee rate");
let response = self.client.get(&url).send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(PolymarketError::api(status.as_u16(), body));
}
let result: FeeRateResponse = response.json().await.map_err(|e| {
PolymarketError::parse_with_source(format!("Failed to parse fee_rate response: {e}"), e)
})?;
debug!(token_id = %token_id, fee_rate_bps = %result.base_fee, "Got fee rate");
Ok(result.base_fee)
}
#[instrument(skip(self))]
pub async fn check_orderbook_exists(&self, token_id: &str) -> Result<bool> {
self.wait_for_rate_limit().await;
let endpoint = "/book";
let url = format!("{}{}?token_id={}", self.config.base_url, endpoint, token_id);
debug!(token_id = %token_id, "Checking orderbook existence");
let response = self.client.get(&url).send().await?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if body.contains("does not exist") || body.contains("No orderbook") {
info!(token_id = %token_id, "Orderbook does not exist");
return Ok(false);
}
if !status.is_success() {
return Err(PolymarketError::api(status.as_u16(), body));
}
debug!(token_id = %token_id, "Orderbook exists");
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clob_config_default() {
let config = ClobConfig::default();
assert_eq!(config.base_url, clob_api_url());
assert_eq!(config.timeout, Duration::from_secs(30));
assert_eq!(config.rate_limit_per_second, 5);
}
#[test]
fn test_clob_config_with_base_url() {
let config = ClobConfig::default().with_base_url("https://custom.example.com");
assert_eq!(config.base_url, "https://custom.example.com");
}
}