use polyoxide_core::{
HttpClient, HttpClientBuilder, RateLimiter, RetryConfig, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS,
};
use crate::{
account::{Account, Credentials},
api::{
account::AccountApi, auth::Auth, notifications::Notifications, orders::OrderResponse,
rewards::Rewards, rfq::Rfq, Health, Markets, Orders,
},
core::chain::Chain,
error::ClobError,
request::{AuthMode, Request},
types::*,
utils::{
calculate_market_order_amounts, calculate_market_price, calculate_order_amounts,
generate_salt,
},
};
use alloy::primitives::Address;
#[cfg(feature = "gamma")]
use polyoxide_gamma::Gamma;
const DEFAULT_BASE_URL: &str = "https://clob.polymarket.com";
#[derive(Clone)]
pub struct Clob {
pub(crate) http_client: HttpClient,
pub(crate) chain_id: u64,
pub(crate) account: Option<Account>,
#[cfg(feature = "gamma")]
pub(crate) gamma: Gamma,
}
impl Clob {
pub fn new(
private_key: impl Into<String>,
credentials: Credentials,
) -> Result<Self, ClobError> {
Self::builder(private_key, credentials)?.build()
}
pub fn public() -> Self {
ClobBuilder::new().build().unwrap() }
pub fn builder(
private_key: impl Into<String>,
credentials: Credentials,
) -> Result<ClobBuilder, ClobError> {
let account = Account::new(private_key, credentials)?;
Ok(ClobBuilder::new().with_account(account))
}
pub fn from_account(account: Account) -> Result<Self, ClobError> {
ClobBuilder::new().with_account(account).build()
}
pub fn account(&self) -> Option<&Account> {
self.account.as_ref()
}
pub fn markets(&self) -> Markets {
Markets {
http_client: self.http_client.clone(),
chain_id: self.chain_id,
}
}
pub fn health(&self) -> Health {
Health {
http_client: self.http_client.clone(),
chain_id: self.chain_id,
}
}
pub fn orders(&self) -> Result<Orders, ClobError> {
let account = self
.account
.as_ref()
.ok_or_else(|| ClobError::validation("Account required for orders API"))?;
Ok(Orders {
http_client: self.http_client.clone(),
wallet: account.wallet().clone(),
credentials: account.credentials().clone(),
signer: account.signer().clone(),
chain_id: self.chain_id,
})
}
pub fn account_api(&self) -> Result<AccountApi, ClobError> {
let account = self
.account
.as_ref()
.ok_or_else(|| ClobError::validation("Account required for account API"))?;
Ok(AccountApi {
http_client: self.http_client.clone(),
wallet: account.wallet().clone(),
credentials: account.credentials().clone(),
signer: account.signer().clone(),
chain_id: self.chain_id,
})
}
pub fn notifications(&self) -> Result<Notifications, ClobError> {
let account = self
.account
.as_ref()
.ok_or_else(|| ClobError::validation("Account required for notifications API"))?;
Ok(Notifications {
http_client: self.http_client.clone(),
wallet: account.wallet().clone(),
credentials: account.credentials().clone(),
signer: account.signer().clone(),
chain_id: self.chain_id,
})
}
pub fn rfq(&self) -> Result<Rfq, ClobError> {
let account = self
.account
.as_ref()
.ok_or_else(|| ClobError::validation("Account required for RFQ API"))?;
Ok(Rfq {
http_client: self.http_client.clone(),
wallet: account.wallet().clone(),
credentials: account.credentials().clone(),
signer: account.signer().clone(),
chain_id: self.chain_id,
})
}
pub fn rewards(&self) -> Result<Rewards, ClobError> {
let account = self
.account
.as_ref()
.ok_or_else(|| ClobError::validation("Account required for rewards API"))?;
Ok(Rewards {
http_client: self.http_client.clone(),
wallet: account.wallet().clone(),
credentials: account.credentials().clone(),
signer: account.signer().clone(),
chain_id: self.chain_id,
})
}
pub fn auth(&self) -> Result<Auth, ClobError> {
let account = self
.account
.as_ref()
.ok_or_else(|| ClobError::validation("Account required for auth API"))?;
Ok(Auth {
http_client: self.http_client.clone(),
wallet: account.wallet().clone(),
credentials: account.credentials().clone(),
signer: account.signer().clone(),
chain_id: self.chain_id,
})
}
pub async fn create_order(
&self,
params: &CreateOrderParams,
options: Option<PartialCreateOrderOptions>,
) -> Result<Order, ClobError> {
let account = self
.account
.as_ref()
.ok_or_else(|| ClobError::validation("Account required to create order"))?;
params.validate()?;
let (neg_risk, tick_size) = self.get_market_metadata(¶ms.token_id, options).await?;
let fee_rate_bps = self.get_fee_rate(¶ms.token_id).await?;
let (maker_amount, taker_amount) =
calculate_order_amounts(params.price, params.size, params.side, tick_size);
let signature_type = params.signature_type.unwrap_or_default();
let maker = self
.resolve_maker_address(params.funder, signature_type, account)
.await?;
Ok(Self::build_order(
params.token_id.clone(),
maker,
account.address(),
maker_amount,
taker_amount,
fee_rate_bps,
params.side,
signature_type,
neg_risk,
params.expiration,
))
}
pub async fn create_market_order(
&self,
params: &MarketOrderArgs,
options: Option<PartialCreateOrderOptions>,
) -> Result<Order, ClobError> {
let account = self
.account
.as_ref()
.ok_or_else(|| ClobError::validation("Account required to create order"))?;
if !params.amount.is_finite() {
return Err(ClobError::validation(
"Amount must be finite (no NaN or infinity)",
));
}
if params.amount <= 0.0 {
return Err(ClobError::validation(format!(
"Amount must be positive, got {}",
params.amount
)));
}
if let Some(p) = params.price {
if !p.is_finite() || p <= 0.0 || p > 1.0 {
return Err(ClobError::validation(format!(
"Price must be finite and between 0.0 and 1.0, got {}",
p
)));
}
}
let (neg_risk, tick_size) = self.get_market_metadata(¶ms.token_id, options).await?;
let price = if let Some(p) = params.price {
p
} else {
let book = self
.markets()
.order_book(params.token_id.clone())
.send()
.await?;
let levels = match params.side {
OrderSide::Buy => book.asks,
OrderSide::Sell => book.bids,
};
calculate_market_price(&levels, params.amount, params.side)
.ok_or_else(|| ClobError::validation("Not enough liquidity to fill market order"))?
};
let fee_rate_bps = self.get_fee_rate(¶ms.token_id).await?;
let (maker_amount, taker_amount) =
calculate_market_order_amounts(params.amount, price, params.side, tick_size);
let signature_type = params.signature_type.unwrap_or_default();
let maker = self
.resolve_maker_address(params.funder, signature_type, account)
.await?;
Ok(Self::build_order(
params.token_id.clone(),
maker,
account.address(),
maker_amount,
taker_amount,
fee_rate_bps,
params.side,
signature_type,
neg_risk,
Some(0),
))
}
pub async fn sign_order(&self, order: &Order) -> Result<SignedOrder, ClobError> {
let account = self
.account
.as_ref()
.ok_or_else(|| ClobError::validation("Account required to sign order"))?;
account.sign_order(order, self.chain_id).await
}
async fn get_market_metadata(
&self,
token_id: &str,
options: Option<PartialCreateOrderOptions>,
) -> Result<(bool, TickSize), ClobError> {
let neg_risk = if let Some(neg_risk) = options.and_then(|o| o.neg_risk) {
neg_risk
} else {
let neg_risk_resp = self.markets().neg_risk(token_id.to_string()).send().await?;
neg_risk_resp.neg_risk
};
let tick_size = if let Some(tick_size) = options.and_then(|o| o.tick_size) {
tick_size
} else {
let tick_size_resp = self
.markets()
.tick_size(token_id.to_string())
.send()
.await?;
let tick_size_val = tick_size_resp
.minimum_tick_size
.parse::<f64>()
.map_err(|e| {
ClobError::validation(format!("Invalid minimum_tick_size field: {}", e))
})?;
TickSize::try_from(tick_size_val)?
};
Ok((neg_risk, tick_size))
}
async fn get_fee_rate(&self, token_id: &str) -> Result<String, ClobError> {
let resp = self.markets().fee_rate(token_id).send().await?;
Ok(resp.base_fee.to_string())
}
async fn resolve_maker_address(
&self,
funder: Option<Address>,
signature_type: SignatureType,
account: &Account,
) -> Result<Address, ClobError> {
if let Some(funder) = funder {
Ok(funder)
} else if signature_type.is_proxy() {
#[cfg(feature = "gamma")]
{
let profile = self
.gamma
.user()
.get(account.address().to_string())
.send()
.await
.map_err(|e| {
ClobError::service(format!("Failed to fetch user profile: {}", e))
})?;
profile
.proxy
.ok_or_else(|| {
ClobError::validation(format!(
"Signature type {:?} requires proxy, but none found for {}",
signature_type,
account.address()
))
})?
.parse::<Address>()
.map_err(|e| {
ClobError::validation(format!(
"Invalid proxy address format from Gamma: {}",
e
))
})
}
#[cfg(not(feature = "gamma"))]
{
Err(ClobError::validation(format!(
"Signature type {:?} requires the `gamma` feature to resolve proxy address; \
enable `polyoxide-clob/gamma` or provide an explicit `funder` address",
signature_type
)))
}
} else {
Ok(account.address())
}
}
#[allow(clippy::too_many_arguments)]
fn build_order(
token_id: String,
maker: Address,
signer: Address,
maker_amount: String,
taker_amount: String,
fee_rate_bps: String,
side: OrderSide,
signature_type: SignatureType,
neg_risk: bool,
expiration: Option<u64>,
) -> Order {
Order {
salt: generate_salt(),
maker,
signer,
taker: alloy::primitives::Address::ZERO,
token_id,
maker_amount,
taker_amount,
expiration: expiration.unwrap_or(0).to_string(),
nonce: "0".to_string(),
fee_rate_bps,
side,
signature_type,
neg_risk,
}
}
pub async fn post_orders(
&self,
orders: &[SignedOrderPayload],
) -> Result<Vec<OrderResponse>, ClobError> {
let account = self
.account
.as_ref()
.ok_or_else(|| ClobError::validation("Account required to post orders"))?;
let auth = AuthMode::L2 {
address: account.address(),
credentials: account.credentials().clone(),
signer: account.signer().clone(),
};
let payload: Vec<_> = orders
.iter()
.map(|o| {
serde_json::json!({
"order": o.order,
"owner": account.credentials().key,
"orderType": o.order_type,
"postOnly": o.post_only,
})
})
.collect();
Request::post(
self.http_client.clone(),
"/orders".to_string(),
auth,
self.chain_id,
)
.body(&payload)?
.send()
.await
}
pub async fn post_order(
&self,
signed_order: &SignedOrder,
order_type: OrderKind,
post_only: bool,
) -> Result<OrderResponse, ClobError> {
let account = self
.account
.as_ref()
.ok_or_else(|| ClobError::validation("Account required to post order"))?;
let auth = AuthMode::L2 {
address: account.address(),
credentials: account.credentials().clone(),
signer: account.signer().clone(),
};
let payload = serde_json::json!({
"order": signed_order,
"owner": account.credentials().key,
"orderType": order_type,
"postOnly": post_only,
});
Request::post(
self.http_client.clone(),
"/order".to_string(),
auth,
self.chain_id,
)
.body(&payload)?
.send()
.await
}
pub async fn place_order(
&self,
params: &CreateOrderParams,
options: Option<PartialCreateOrderOptions>,
) -> Result<OrderResponse, ClobError> {
let order = self.create_order(params, options).await?;
let signed_order = self.sign_order(&order).await?;
self.post_order(&signed_order, params.order_type, params.post_only)
.await
}
pub async fn place_market_order(
&self,
params: &MarketOrderArgs,
options: Option<PartialCreateOrderOptions>,
) -> Result<OrderResponse, ClobError> {
let order = self.create_market_order(params, options).await?;
let signed_order = self.sign_order(&order).await?;
let order_type = params.order_type.unwrap_or(OrderKind::Fok);
self.post_order(&signed_order, order_type, false) .await
}
}
#[derive(Debug, Clone)]
pub struct CreateOrderParams {
pub token_id: String,
pub price: f64,
pub size: f64,
pub side: OrderSide,
pub order_type: OrderKind,
pub post_only: bool,
pub expiration: Option<u64>,
pub funder: Option<Address>,
pub signature_type: Option<SignatureType>,
}
impl CreateOrderParams {
pub fn validate(&self) -> Result<(), ClobError> {
if !self.price.is_finite() || !self.size.is_finite() {
return Err(ClobError::validation(
"Price and size must be finite (no NaN or infinity)",
));
}
if self.price <= 0.0 || self.price > 1.0 {
return Err(ClobError::validation(format!(
"Price must be between 0.0 and 1.0, got {}",
self.price
)));
}
if self.size <= 0.0 {
return Err(ClobError::validation(format!(
"Size must be positive, got {}",
self.size
)));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SignedOrderPayload {
pub order: SignedOrder,
pub order_type: OrderKind,
pub post_only: bool,
}
pub struct ClobBuilder {
base_url: String,
timeout_ms: u64,
pool_size: usize,
chain: Chain,
account: Option<Account>,
#[cfg(feature = "gamma")]
gamma: Option<Gamma>,
retry_config: Option<RetryConfig>,
max_concurrent: Option<usize>,
}
impl ClobBuilder {
pub fn new() -> Self {
Self {
base_url: DEFAULT_BASE_URL.to_string(),
timeout_ms: DEFAULT_TIMEOUT_MS,
pool_size: DEFAULT_POOL_SIZE,
chain: Chain::PolygonMainnet,
account: None,
#[cfg(feature = "gamma")]
gamma: None,
retry_config: None,
max_concurrent: None,
}
}
pub fn with_account(mut self, account: Account) -> Self {
self.account = Some(account);
self
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
pub fn timeout_ms(mut self, timeout: u64) -> Self {
self.timeout_ms = timeout;
self
}
pub fn pool_size(mut self, size: usize) -> Self {
self.pool_size = size;
self
}
pub fn chain(mut self, chain: Chain) -> Self {
self.chain = chain;
self
}
#[cfg(feature = "gamma")]
pub fn gamma(mut self, gamma: Gamma) -> Self {
self.gamma = Some(gamma);
self
}
pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
self.retry_config = Some(config);
self
}
pub fn max_concurrent(mut self, max: usize) -> Self {
self.max_concurrent = Some(max);
self
}
pub fn build(self) -> Result<Clob, ClobError> {
let mut builder = HttpClientBuilder::new(&self.base_url)
.timeout_ms(self.timeout_ms)
.pool_size(self.pool_size)
.with_rate_limiter(RateLimiter::clob_default())
.with_max_concurrent(self.max_concurrent.unwrap_or(8));
if let Some(config) = self.retry_config {
builder = builder.with_retry_config(config);
}
let http_client = builder.build()?;
#[cfg(feature = "gamma")]
let gamma = if let Some(gamma) = self.gamma {
gamma
} else {
polyoxide_gamma::Gamma::builder()
.timeout_ms(self.timeout_ms)
.pool_size(self.pool_size)
.build()
.map_err(|e| {
ClobError::service(format!("Failed to build default Gamma client: {}", e))
})?
};
Ok(Clob {
http_client,
chain_id: self.chain.chain_id(),
account: self.account,
#[cfg(feature = "gamma")]
gamma,
})
}
}
impl Default for ClobBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_custom_max_concurrent() {
let builder = ClobBuilder::new().max_concurrent(16);
assert_eq!(builder.max_concurrent, Some(16));
}
#[tokio::test]
async fn test_default_concurrency_limit_is_8() {
let clob = Clob::public();
let mut permits = Vec::new();
for _ in 0..8 {
permits.push(clob.http_client.acquire_concurrency().await);
}
assert!(permits.iter().all(|p| p.is_some()));
let result = tokio::time::timeout(
std::time::Duration::from_millis(50),
clob.http_client.acquire_concurrency(),
)
.await;
assert!(
result.is_err(),
"9th permit should block with default limit of 8"
);
}
#[test]
fn test_builder_custom_retry_config() {
let config = RetryConfig {
max_retries: 5,
initial_backoff_ms: 1000,
max_backoff_ms: 30_000,
};
let builder = ClobBuilder::new().with_retry_config(config);
let config = builder.retry_config.unwrap();
assert_eq!(config.max_retries, 5);
assert_eq!(config.initial_backoff_ms, 1000);
}
fn make_params(price: f64, size: f64) -> CreateOrderParams {
CreateOrderParams {
token_id: "test".to_string(),
price,
size,
side: OrderSide::Buy,
order_type: OrderKind::Gtc,
post_only: false,
expiration: None,
funder: None,
signature_type: None,
}
}
#[test]
fn test_validate_rejects_nan_price() {
let params = make_params(f64::NAN, 100.0);
let err = params.validate().unwrap_err();
assert!(err.to_string().contains("finite"));
}
#[test]
fn test_validate_rejects_nan_size() {
let params = make_params(0.5, f64::NAN);
let err = params.validate().unwrap_err();
assert!(err.to_string().contains("finite"));
}
#[test]
fn test_validate_rejects_infinite_price() {
let params = make_params(f64::INFINITY, 100.0);
let err = params.validate().unwrap_err();
assert!(err.to_string().contains("finite"));
}
#[test]
fn test_validate_rejects_infinite_size() {
let params = make_params(0.5, f64::INFINITY);
let err = params.validate().unwrap_err();
assert!(err.to_string().contains("finite"));
}
#[test]
fn test_validate_rejects_neg_infinity_size() {
let params = make_params(0.5, f64::NEG_INFINITY);
let err = params.validate().unwrap_err();
assert!(err.to_string().contains("finite"));
}
#[test]
fn test_validate_rejects_price_out_of_range() {
let params = make_params(1.5, 100.0);
let err = params.validate().unwrap_err();
assert!(err.to_string().contains("between 0.0 and 1.0"));
}
#[test]
fn test_validate_rejects_zero_price() {
let params = make_params(0.0, 100.0);
let err = params.validate().unwrap_err();
assert!(err.to_string().contains("between 0.0 and 1.0"));
}
#[test]
fn test_validate_rejects_negative_size() {
let params = make_params(0.5, -10.0);
let err = params.validate().unwrap_err();
assert!(err.to_string().contains("positive"));
}
#[test]
fn test_validate_accepts_valid_params() {
let params = make_params(0.5, 100.0);
assert!(params.validate().is_ok());
}
#[test]
fn test_validate_accepts_boundary_price() {
let params = make_params(1.0, 100.0);
assert!(params.validate().is_ok());
}
}