From e78ae17286790277307df97a6543aed6545888b8 Mon Sep 17 00:00:00 2001 From: floor-licker Date: Fri, 24 Apr 2026 12:48:30 -0300 Subject: [PATCH] feat: migrate CLOB client to V2-only trading --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 6 +- benches/network_benchmarks.rs | 8 +- examples/benchmark_with_keepalive.rs | 2 +- examples/demo.rs | 20 +- examples/final_benchmark.rs | 2 +- examples/http2_tuning_benchmark.rs | 2 +- examples/performance_benchmark.rs | 2 +- examples/quick_demo.rs | 2 +- examples/side_by_side_benchmark.rs | 4 +- src/auth.rs | 46 +- src/client.rs | 756 +++++++++++++++------- src/connection_manager.rs | 4 +- src/dns_cache.rs | 6 +- src/lib.rs | 33 +- src/orders.rs | 420 ++++++++++-- src/types.rs | 213 ++++-- tests/common/mod.rs | 28 +- tests/integration_tests.rs | 78 ++- tests/order_posting_test.rs | 30 +- tests/prices_history_integration_tests.rs | 2 +- tests/simple_auth_test.rs | 64 +- tests/ws_integration_tests.rs | 12 +- 24 files changed, 1281 insertions(+), 463 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46f5b7f..38fe350 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2823,7 +2823,7 @@ dependencies = [ [[package]] name = "polyfill-rs" -version = "0.3.1" +version = "0.4.0" dependencies = [ "alloy-primitives", "alloy-signer", diff --git a/Cargo.toml b/Cargo.toml index d6ad4ff..28d6983 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "polyfill-rs" -version = "0.3.1" +version = "0.4.0" edition = "2021" authors = ["Julius Tranquilli "] description = "The Fastest Polymarket Client On The Market." diff --git a/README.md b/README.md index 040019a..41db44b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Documentation](https://docs.rs/polyfill-rs/badge.svg)](https://docs.rs/polyfill-rs) [![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)](LICENSE) -A high-performance Polymarket Rust client with latency-optimized data structures and zero-allocation hot paths. An API-compatible drop-in replacement for `polymarket-rs-client` with identical method signatures. +A high-performance Polymarket Rust client with latency-optimized data structures and zero-allocation hot paths. The `0.4.x` line is V2-native and intentionally breaking for authenticated trading flows. At the time that this project was started, `polymarket-rs-client` was a Polymarket Rust Client with a few GitHub stars, but which seemed to be unmaintained. I took on the task of creating a Rust client which could beat the benchmarks quoted in the README.md of that project, with the added constraint of also maintaining zero alloc hot paths. @@ -21,7 +21,7 @@ Add to your `Cargo.toml`: ```toml [dependencies] -polyfill-rs = "0.3.0" +polyfill-rs = "0.4.0" ``` Replace your imports: @@ -32,7 +32,7 @@ use polyfill_rs::{ClobClient, Side, OrderType}; #[tokio::main] async fn main() -> Result<(), Box> { - let client = ClobClient::new("https://clob.polymarket.com"); + let client = ClobClient::new("https://clob-v2.polymarket.com"); let markets = client.get_sampling_markets(None).await?; println!("Found {} markets", markets.data.len()); Ok(()) diff --git a/benches/network_benchmarks.rs b/benches/network_benchmarks.rs index fd85016..c993a6e 100644 --- a/benches/network_benchmarks.rs +++ b/benches/network_benchmarks.rs @@ -11,7 +11,7 @@ fn benchmark_real_simplified_markets(c: &mut Criterion) { c.bench_function("real_fetch_simplified_markets", |b| { b.iter(|| { rt.block_on(async { - let client = ClobClient::new("https://clob.polymarket.com"); + let client = ClobClient::new("https://clob-v2.polymarket.com"); // This is the real network request + JSON parsing let result = client.get_sampling_simplified_markets(None).await; @@ -28,7 +28,7 @@ fn benchmark_real_markets(c: &mut Criterion) { c.bench_function("real_fetch_markets", |b| { b.iter(|| { rt.block_on(async { - let client = ClobClient::new("https://clob.polymarket.com"); + let client = ClobClient::new("https://clob-v2.polymarket.com"); // This is the real network request + JSON parsing let result = client.get_sampling_markets(None).await; @@ -52,7 +52,7 @@ fn benchmark_real_order_creation(c: &mut Criterion) { c.bench_function("real_create_order_eip712", |b| { b.iter(|| { rt.block_on(async { - let client = ClobClient::new("https://clob.polymarket.com"); + let client = ClobClient::new("https://clob-v2.polymarket.com"); // Set up credentials if let Ok(_key) = std::env::var("POLYMARKET_PRIVATE_KEY") { @@ -69,7 +69,7 @@ fn benchmark_real_order_creation(c: &mut Criterion) { ); // This is the real EIP-712 signing + network request - let result = client.create_order(&order_args, None, None, None).await; + let result = client.create_order(&order_args, None).await; black_box(result) }) }) diff --git a/examples/benchmark_with_keepalive.rs b/examples/benchmark_with_keepalive.rs index 2fd7ccb..efed0e0 100644 --- a/examples/benchmark_with_keepalive.rs +++ b/examples/benchmark_with_keepalive.rs @@ -6,7 +6,7 @@ async fn main() -> Result<(), Box> { println!("Benchmark with Keep-Alive Enabled"); println!("==================================\n"); - let client = ClobClient::new("https://clob.polymarket.com"); + let client = ClobClient::new("https://clob-v2.polymarket.com"); // Start keep-alive println!("Starting keep-alive..."); diff --git a/examples/demo.rs b/examples/demo.rs index 7fb6319..ec6da27 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -94,21 +94,19 @@ impl PolyfillDemo { /// Create a new demo pub fn new() -> Result { // Create basic client - let client = ClobClient::new("https://clob.polymarket.com"); - - // Create advanced client with configuration - let _config = ClientConfig { - base_url: "https://clob.polymarket.com".to_string(), - chain_id: 137, // Polygon - private_key: None, // Would be set in production - api_credentials: None, // Would be set in production - max_slippage: Some(dec!(0.01)), // 1% max slippage - fee_rate: Some(dec!(0.02)), // 2% fee rate + let config = ClientConfig { + base_url: "https://clob-v2.polymarket.com".to_string(), + chain: 137, + private_key: None, + api_credentials: None, + builder_code: None, timeout: Some(Duration::from_secs(30)), max_connections: Some(100), }; + let client = ClobClient::new(&config.base_url); - let advanced_client = PolyfillClient::new("https://clob.polymarket.com"); + // Create advanced client with configuration + let advanced_client = PolyfillClient::from_config(config.clone())?; // Create order book manager let book_manager = OrderBookManager::new(100); diff --git a/examples/final_benchmark.rs b/examples/final_benchmark.rs index 5b501b1..5e98c30 100644 --- a/examples/final_benchmark.rs +++ b/examples/final_benchmark.rs @@ -20,7 +20,7 @@ async fn main() -> Result<(), Box> { for i in 1..=20 { let start = Instant::now(); let response = client - .get("https://clob.polymarket.com/simplified-markets?next_cursor=MA==") + .get("https://clob-v2.polymarket.com/simplified-markets?next_cursor=MA==") .send() .await?; diff --git a/examples/http2_tuning_benchmark.rs b/examples/http2_tuning_benchmark.rs index 52aebd3..9607282 100644 --- a/examples/http2_tuning_benchmark.rs +++ b/examples/http2_tuning_benchmark.rs @@ -184,7 +184,7 @@ async fn test_config( let start = Instant::now(); match client - .get("https://clob.polymarket.com/simplified-markets?next_cursor=MA==") + .get("https://clob-v2.polymarket.com/simplified-markets?next_cursor=MA==") .send() .await { diff --git a/examples/performance_benchmark.rs b/examples/performance_benchmark.rs index e8252f7..c42c046 100644 --- a/examples/performance_benchmark.rs +++ b/examples/performance_benchmark.rs @@ -127,7 +127,7 @@ async fn main() -> Result<(), Box> { }; // Create client with API credentials only (no private key needed for custodial trading) - let mut client = ClobClient::new("https://clob.polymarket.com"); + let mut client = ClobClient::new("https://clob-v2.polymarket.com"); client.set_api_creds(api_creds); println!("✅ Client configured for custodial API trading"); diff --git a/examples/quick_demo.rs b/examples/quick_demo.rs index 9aa37de..d63cc7b 100644 --- a/examples/quick_demo.rs +++ b/examples/quick_demo.rs @@ -18,7 +18,7 @@ async fn main() -> Result<()> { info!("======================"); // Create client - let client = ClobClient::new("https://clob.polymarket.com"); + let client = ClobClient::new("https://clob-v2.polymarket.com"); // Test 1: Basic connectivity info!("\nTesting API Connectivity..."); diff --git a/examples/side_by_side_benchmark.rs b/examples/side_by_side_benchmark.rs index c633a16..546d32c 100644 --- a/examples/side_by_side_benchmark.rs +++ b/examples/side_by_side_benchmark.rs @@ -29,7 +29,7 @@ async fn main() -> Result<(), Box> { for i in 1..=20 { let start = Instant::now(); match baseline_http - .get("https://clob.polymarket.com/simplified-markets?next_cursor=MA==") + .get("https://clob-v2.polymarket.com/simplified-markets?next_cursor=MA==") .send() .await { @@ -68,7 +68,7 @@ async fn main() -> Result<(), Box> { println!("Test 2: polyfill-rs (with keep-alive)"); println!("══════════════════════════════════════"); - let our_client = polyfill_rs::ClobClient::new("https://clob.polymarket.com"); + let our_client = polyfill_rs::ClobClient::new("https://clob-v2.polymarket.com"); our_client .start_keepalive(std::time::Duration::from_secs(30)) .await; diff --git a/src/auth.rs b/src/auth.rs index a135ca6..5200222 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -5,7 +5,7 @@ use crate::errors::{PolyfillError, Result}; use crate::types::ApiCredentials; -use alloy_primitives::{hex::encode_prefixed, Address, U256}; +use alloy_primitives::{hex::encode_prefixed, Address, B256, U256}; use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; use alloy_sol_types::{eip712_domain, sol}; @@ -42,18 +42,34 @@ sol! { uint256 salt; address maker; address signer; - address taker; uint256 tokenId; uint256 makerAmount; uint256 takerAmount; - uint256 expiration; - uint256 nonce; - uint256 feeRateBps; uint8 side; uint8 signatureType; + uint256 timestamp; + bytes32 metadata; + bytes32 builder; } } +/// V2 order signing payload. The REST body still carries `expiration`, but the EIP-712 payload +/// follows the V2 exchange struct. +#[derive(Clone)] +pub struct SignedOrderMessage { + pub salt: U256, + pub maker: Address, + pub signer: Address, + pub token_id: U256, + pub maker_amount: U256, + pub taker_amount: U256, + pub side: u8, + pub signature_type: u8, + pub timestamp: U256, + pub metadata: B256, + pub builder: B256, +} + /// Get current Unix timestamp in seconds pub fn get_current_unix_time_secs() -> u64 { SystemTime::now() @@ -94,13 +110,27 @@ pub fn sign_clob_auth_message( /// Sign order message using EIP-712 pub fn sign_order_message( signer: &PrivateKeySigner, - order: Order, + order: SignedOrderMessage, chain_id: u64, verifying_contract: Address, ) -> Result { + let order = Order { + salt: order.salt, + maker: order.maker, + signer: order.signer, + tokenId: order.token_id, + makerAmount: order.maker_amount, + takerAmount: order.taker_amount, + side: order.side, + signatureType: order.signature_type, + timestamp: order.timestamp, + metadata: order.metadata, + builder: order.builder, + }; + let domain = eip712_domain!( name: "Polymarket CTF Exchange", - version: "1", + version: "2", chain_id: chain_id, verifying_contract: verifying_contract, ); @@ -335,7 +365,7 @@ mod tests { passphrase: "test_passphrase".to_string(), }; - let result = create_l2_headers::(&signer, &api_creds, "/test", "GET", None); + let result = create_l2_headers::(&signer, &api_creds, "GET", "/test", None); assert!(result.is_ok()); let headers = result.unwrap(); diff --git a/src/client.rs b/src/client.rs index 938dfb8..b36d7ec 100644 --- a/src/client.rs +++ b/src/client.rs @@ -6,9 +6,13 @@ use crate::auth::{create_l1_headers, create_l2_headers}; use crate::errors::{PolyfillError, Result}; use crate::http_config::{ - create_colocated_client, create_internet_client, create_optimized_client, prewarm_connections, + create_colocated_client, create_internet_client, prewarm_connections, +}; +use crate::types::{ + CancelOrdersResponse, ClientConfig, ClobMarketInfo, CreateOrderOptions, MarketOrderArgs, + OrderArgs, OrderType, PostOrder, PostOrderOptions, PostOrderResponse, SignedOrderRequest, + Side, }; -use crate::types::{OrderOptions, PostOrder, SignedOrderRequest}; use alloy_primitives::U256; use alloy_signer_local::PrivateKeySigner; use reqwest::header::HeaderName; @@ -18,39 +22,36 @@ use rust_decimal::prelude::FromPrimitive; use rust_decimal::Decimal; use serde_json::Value; use std::str::FromStr; +use std::time::Duration; // Re-export types for compatibility -pub use crate::types::{ApiCredentials as ApiCreds, OrderType, Side}; +pub use crate::types::{ApiCredentials as ApiCreds, MarketOrderArgs as ClientMarketOrderArgs}; -// Compatibility types -#[derive(Debug)] -pub struct OrderArgs { - pub token_id: String, - pub price: Decimal, - pub size: Decimal, - pub side: Side, +#[derive(Debug, Clone, serde::Deserialize)] +struct MarketByTokenResponse { + condition_id: String, } -impl OrderArgs { - pub fn new(token_id: &str, price: Decimal, size: Decimal, side: Side) -> Self { - Self { - token_id: token_id.to_string(), - price, - size, - side, - } - } -} +fn build_http_client(timeout: Option, max_connections: Option) -> Client { + let max_connections = max_connections.unwrap_or(10); + let mut builder = reqwest::ClientBuilder::new() + .no_proxy() + .http2_adaptive_window(true) + .http2_initial_stream_window_size(512 * 1024) + .tcp_nodelay(true) + .pool_max_idle_per_host(max_connections) + .pool_idle_timeout(Duration::from_secs(90)); -impl Default for OrderArgs { - fn default() -> Self { - Self { - token_id: "".to_string(), - price: Decimal::ZERO, - size: Decimal::ZERO, - side: Side::BUY, - } + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); } + + builder.build().unwrap_or_else(|_| { + reqwest::ClientBuilder::new() + .no_proxy() + .build() + .expect("Failed to build reqwest client") + }) } /// Main client for interacting with Polymarket API @@ -60,6 +61,7 @@ pub struct ClobClient { chain_id: u64, signer: Option, api_creds: Option, + builder_code: Option, order_builder: Option, #[allow(dead_code)] dns_cache: Option>, @@ -70,28 +72,14 @@ pub struct ClobClient { } impl ClobClient { - /// Create a new client with optimized HTTP/2 settings (benchmarked 11.4% faster) - /// Now includes DNS caching, connection management, and buffer pooling - pub fn new(host: &str) -> Self { - // Benchmarked optimal configuration: 512KB stream window - // Results: 309.3ms vs 349ms baseline (11.4% improvement) - let optimized_client = reqwest::ClientBuilder::new() - // Avoid reading OS proxy settings (can be slow and/or unavailable in some sandboxed envs) - .no_proxy() - .http2_adaptive_window(true) - .http2_initial_stream_window_size(512 * 1024) // 512KB - empirically optimal - .tcp_nodelay(true) - .pool_max_idle_per_host(10) - .pool_idle_timeout(std::time::Duration::from_secs(90)) - .build() - .unwrap_or_else(|_| { - reqwest::ClientBuilder::new() - .no_proxy() - .build() - .expect("Failed to build reqwest client") - }); - - // Initialize DNS cache and pre-warm it + fn build_client( + host: &str, + chain_id: u64, + http_client: Client, + signer: Option, + api_creds: Option, + builder_code: Option, + ) -> Self { let dns_cache = tokio::runtime::Handle::try_current().ok().and_then(|_| { tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { @@ -107,18 +95,11 @@ impl ClobClient { }) }); - // Initialize connection manager let connection_manager = Some(std::sync::Arc::new( - crate::connection_manager::ConnectionManager::new( - optimized_client.clone(), - host.to_string(), - ), + crate::connection_manager::ConnectionManager::new(http_client.clone(), host.to_string()), )); - - // Initialize buffer pool (512KB buffers, pool of 10) let buffer_pool = std::sync::Arc::new(crate::buffer_pool::BufferPool::new(512 * 1024, 10)); - // Pre-warm buffer pool with 3 buffers let pool_clone = buffer_pool.clone(); if let Ok(_handle) = tokio::runtime::Handle::try_current() { tokio::spawn(async move { @@ -126,19 +107,54 @@ impl ClobClient { }); } + let order_builder = signer + .clone() + .map(|signer| crate::orders::OrderBuilder::new(signer, None, None)); + Self { - http_client: optimized_client, + http_client, base_url: host.to_string(), - chain_id: 137, // Default to Polygon - signer: None, - api_creds: None, - order_builder: None, + chain_id, + signer, + api_creds, + builder_code, + order_builder, dns_cache, connection_manager, buffer_pool, } } + /// Create a new client with optimized HTTP/2 settings (benchmarked 11.4% faster) + /// Now includes DNS caching, connection management, and buffer pooling + pub fn new(host: &str) -> Self { + let http_client = build_http_client(None, None); + Self::build_client(host, 137, http_client, None, None, None) + } + + /// Create a V2-native client from config. + pub fn from_config(config: ClientConfig) -> Result { + let signer = match config.private_key.as_deref() { + Some(private_key) => Some( + private_key + .parse::() + .map_err(|e| PolyfillError::config(format!("Invalid private key: {e}")))?, + ), + None => None, + }; + + let http_client = build_http_client(config.timeout, config.max_connections); + + Ok(Self::build_client( + &config.base_url, + config.chain, + http_client, + signer, + config.api_credentials, + config.builder_code, + )) + } + /// Create a client optimized for co-located environments pub fn new_colocated(host: &str) -> Self { let http_client = create_colocated_client().unwrap_or_else(|_| { @@ -147,26 +163,7 @@ impl ClobClient { .build() .expect("Failed to build reqwest client") }); - - let connection_manager = Some(std::sync::Arc::new( - crate::connection_manager::ConnectionManager::new( - http_client.clone(), - host.to_string(), - ), - )); - let buffer_pool = std::sync::Arc::new(crate::buffer_pool::BufferPool::new(512 * 1024, 10)); - - Self { - http_client, - base_url: host.to_string(), - chain_id: 137, - signer: None, - api_creds: None, - order_builder: None, - dns_cache: None, - connection_manager, - buffer_pool, - } + Self::build_client(host, 137, http_client, None, None, None) } /// Create a client optimized for internet connections @@ -177,107 +174,37 @@ impl ClobClient { .build() .expect("Failed to build reqwest client") }); - - let connection_manager = Some(std::sync::Arc::new( - crate::connection_manager::ConnectionManager::new( - http_client.clone(), - host.to_string(), - ), - )); - let buffer_pool = std::sync::Arc::new(crate::buffer_pool::BufferPool::new(512 * 1024, 10)); - - Self { - http_client, - base_url: host.to_string(), - chain_id: 137, - signer: None, - api_creds: None, - order_builder: None, - dns_cache: None, - connection_manager, - buffer_pool, - } + Self::build_client(host, 137, http_client, None, None, None) } /// Create a client with L1 headers (for authentication) + #[deprecated(note = "Use ClobClient::from_config(ClientConfig) for authenticated clients")] pub fn with_l1_headers(host: &str, private_key: &str, chain_id: u64) -> Self { - let signer = private_key - .parse::() - .expect("Invalid private key"); - - let order_builder = crate::orders::OrderBuilder::new(signer.clone(), None, None); - - let http_client = create_optimized_client().unwrap_or_else(|_| { - reqwest::ClientBuilder::new() - .no_proxy() - .build() - .expect("Failed to build reqwest client") - }); - - // Initialize infrastructure modules - let dns_cache = None; // Skip DNS cache for simplicity in this constructor - let connection_manager = Some(std::sync::Arc::new( - crate::connection_manager::ConnectionManager::new( - http_client.clone(), - host.to_string(), - ), - )); - let buffer_pool = std::sync::Arc::new(crate::buffer_pool::BufferPool::new(512 * 1024, 10)); - - Self { - http_client, + Self::from_config(ClientConfig { base_url: host.to_string(), - chain_id, - signer: Some(signer), - api_creds: None, - order_builder: Some(order_builder), - dns_cache, - connection_manager, - buffer_pool, - } + chain: chain_id, + private_key: Some(private_key.to_string()), + ..ClientConfig::default() + }) + .expect("failed to build authenticated client") } /// Create a client with L2 headers (for API key authentication) + #[deprecated(note = "Use ClobClient::from_config(ClientConfig) for authenticated clients")] pub fn with_l2_headers( host: &str, private_key: &str, chain_id: u64, api_creds: ApiCreds, ) -> Self { - let signer = private_key - .parse::() - .expect("Invalid private key"); - - let order_builder = crate::orders::OrderBuilder::new(signer.clone(), None, None); - - let http_client = create_optimized_client().unwrap_or_else(|_| { - reqwest::ClientBuilder::new() - .no_proxy() - .build() - .expect("Failed to build reqwest client") - }); - - // Initialize infrastructure modules - let dns_cache = None; // Skip DNS cache for simplicity in this constructor - let connection_manager = Some(std::sync::Arc::new( - crate::connection_manager::ConnectionManager::new( - http_client.clone(), - host.to_string(), - ), - )); - let buffer_pool = std::sync::Arc::new(crate::buffer_pool::BufferPool::new(512 * 1024, 10)); - - Self { - http_client, + Self::from_config(ClientConfig { base_url: host.to_string(), - chain_id, - signer: Some(signer), - api_creds: Some(api_creds), - order_builder: Some(order_builder), - dns_cache, - connection_manager, - buffer_pool, - } + chain: chain_id, + private_key: Some(private_key.to_string()), + api_credentials: Some(api_creds), + ..ClientConfig::default() + }) + .expect("failed to build authenticated client") } /// Set API credentials @@ -487,6 +414,47 @@ impl ClobClient { Ok(price) } + async fn get_market_by_token(&self, token_id: &str) -> Result { + let response = self + .http_client + .get(format!("{}/markets-by-token/{}", self.base_url, token_id)) + .send() + .await?; + + if !response.status().is_success() { + return Err(PolyfillError::api( + response.status().as_u16(), + "Failed to get market by token", + )); + } + + response + .json::() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {e}"), None)) + } + + /// Get V2 CLOB-level market info for a condition ID. + pub async fn get_clob_market_info(&self, condition_id: &str) -> Result { + let response = self + .http_client + .get(format!("{}/clob-markets/{}", self.base_url, condition_id)) + .send() + .await?; + + if !response.status().is_success() { + return Err(PolyfillError::api( + response.status().as_u16(), + "Failed to get clob market info", + )); + } + + response + .json::() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {e}"), None)) + } + fn validate_prices_history_asset_id(asset_id: &str) -> Result<()> { if asset_id.is_empty() { return Err(PolyfillError::validation( @@ -658,7 +626,7 @@ impl ClobClient { .json() .await .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None))?; - Ok(fee_rate.fee_rate_bps) + Ok(fee_rate.base_fee) } /// Create a new API key @@ -855,11 +823,11 @@ impl ClobClient { async fn get_filled_order_options( &self, token_id: &str, - options: Option<&OrderOptions>, - ) -> Result { - let (tick_size, neg_risk, fee_rate_bps) = match options { - Some(o) => (o.tick_size, o.neg_risk, o.fee_rate_bps), - None => (None, None, None), + options: Option<&CreateOrderOptions>, + ) -> Result { + let (tick_size, neg_risk) = match options { + Some(o) => (o.tick_size, o.neg_risk), + None => (None, None), }; let tick_size = self.resolve_tick_size(token_id, tick_size).await?; @@ -868,10 +836,9 @@ impl ClobClient { None => self.get_neg_risk(token_id).await?, }; - Ok(OrderOptions { + Ok(CreateOrderOptions { tick_size: Some(tick_size), neg_risk: Some(neg_risk), - fee_rate_bps, }) } @@ -882,13 +849,16 @@ impl ClobClient { price >= min_price && price <= max_price } + async fn get_clob_market_info_for_token(&self, token_id: &str) -> Result { + let market = self.get_market_by_token(token_id).await?; + self.get_clob_market_info(&market.condition_id).await + } + /// Create an order pub async fn create_order( &self, order_args: &OrderArgs, - expiration: Option, - extras: Option, - options: Option<&OrderOptions>, + options: Option<&CreateOrderOptions>, ) -> Result { let order_builder = self .order_builder @@ -898,9 +868,10 @@ impl ClobClient { let create_order_options = self .get_filled_order_options(&order_args.token_id, options) .await?; - - let expiration = expiration.unwrap_or(0); - let extras = extras.unwrap_or_default(); + let mut order_args = order_args.clone(); + if order_args.builder_code.is_none() { + order_args.builder_code = self.builder_code.clone(); + } if !self.is_price_in_range( order_args.price, @@ -911,13 +882,7 @@ impl ClobClient { )); } - order_builder.create_order( - self.chain_id, - order_args, - expiration, - &extras, - &create_order_options, - ) + order_builder.create_order(self.chain_id, &order_args, &create_order_options) } /// Calculate market price from order book @@ -926,6 +891,7 @@ impl ClobClient { token_id: &str, side: Side, amount: Decimal, + order_type: OrderType, ) -> Result { let book = self.get_order_book(token_id).await?; let order_builder = self @@ -953,15 +919,14 @@ impl ClobClient { .collect(), }; - order_builder.calculate_market_price(&levels, amount) + order_builder.calculate_market_price(&levels, amount, side, order_type) } /// Create a market order pub async fn create_market_order( &self, - order_args: &crate::types::MarketOrderArgs, - extras: Option, - options: Option<&OrderOptions>, + order_args: &MarketOrderArgs, + options: Option<&CreateOrderOptions>, ) -> Result { let order_builder = self .order_builder @@ -971,12 +936,66 @@ impl ClobClient { let create_order_options = self .get_filled_order_options(&order_args.token_id, options) .await?; + if !matches!(order_args.order_type, OrderType::FOK | OrderType::FAK) { + return Err(PolyfillError::validation( + "Market orders only support FOK and FAK order types", + )); + } - let extras = extras.unwrap_or_default(); - let price = self - .calculate_market_price(&order_args.token_id, Side::BUY, order_args.amount) + let mut order_args = order_args.clone(); + if order_args.builder_code.is_none() { + order_args.builder_code = self.builder_code.clone(); + } + + let market_price = self + .calculate_market_price( + &order_args.token_id, + order_args.side, + order_args.amount, + order_args.order_type, + ) .await?; + let price = match order_args.price_limit { + Some(limit) => { + let limit_ok = match order_args.side { + Side::BUY => market_price <= limit, + Side::SELL => market_price >= limit, + }; + if !limit_ok { + return Err(PolyfillError::validation(format!( + "Calculated market price {market_price} violates price_limit {limit}" + ))); + } + limit + }, + None => market_price, + }; + + if order_args.side == Side::BUY { + if let Some(user_balance) = order_args.user_usdc_balance { + let market_info = self.get_clob_market_info_for_token(&order_args.token_id).await?; + let fee_details = market_info.fd.unwrap_or(crate::types::ClobFeeDetails { + r: Decimal::ZERO, + e: 0, + to: false, + }); + + order_args.amount = crate::orders::adjust_buy_amount_for_fees( + order_args.amount, + price, + user_balance, + fee_details.r, + fee_details.e, + if order_args.builder_code.is_some() { + market_info.tbf + } else { + Decimal::ZERO + }, + )?; + } + } + if !self.is_price_in_range( price, create_order_options.tick_size.expect("Should be filled"), @@ -986,21 +1005,15 @@ impl ClobClient { )); } - order_builder.create_market_order( - self.chain_id, - order_args, - price, - &extras, - &create_order_options, - ) + order_builder.create_market_order(self.chain_id, &order_args, price, &create_order_options) } /// Post an order to the exchange pub async fn post_order( &self, order: SignedOrderRequest, - order_type: OrderType, - ) -> Result { + options: Option<&PostOrderOptions>, + ) -> Result { let signer = self .signer .as_ref() @@ -1009,10 +1022,17 @@ impl ClobClient { .api_creds .as_ref() .ok_or_else(|| PolyfillError::auth("API credentials not set"))?; + let options = options.copied().unwrap_or_default(); + + if options.post_only && matches!(options.order_type, OrderType::FOK | OrderType::FAK) { + return Err(PolyfillError::validation( + "post_only is not supported for FOK/FAK orders", + )); + } // Owner field must reference the credential principal identifier // to maintain consistency with the authentication context layer - let body = PostOrder::new(order, api_creds.api_key.clone(), order_type); + let body = PostOrder::new(order, api_creds.api_key.clone(), options); let headers = create_l2_headers(signer, api_creds, "POST", "/order", Some(&body))?; let req = self.create_request_with_headers(Method::POST, "/order", headers.into_iter()); @@ -1029,17 +1049,41 @@ impl ClobClient { return Err(PolyfillError::api(status, message)); } - Ok(response.json::().await?) + response + .json::() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {e}"), None)) } /// Create and post an order in one call - pub async fn create_and_post_order(&self, order_args: &OrderArgs) -> Result { - let order = self.create_order(order_args, None, None, None).await?; - self.post_order(order, OrderType::GTC).await + pub async fn create_and_post_order( + &self, + order_args: &OrderArgs, + create_options: Option<&CreateOrderOptions>, + post_options: Option<&PostOrderOptions>, + ) -> Result { + let order = self.create_order(order_args, create_options).await?; + self.post_order(order, post_options).await + } + + /// Create and post a market order in one call. + pub async fn create_and_post_market_order( + &self, + order_args: &MarketOrderArgs, + create_options: Option<&CreateOrderOptions>, + post_options: Option<&PostOrderOptions>, + ) -> Result { + let post_options = post_options.copied().unwrap_or(PostOrderOptions { + order_type: order_args.order_type, + post_only: false, + defer_exec: false, + }); + let order = self.create_market_order(order_args, create_options).await?; + self.post_order(order, Some(&post_options)).await } /// Cancel an order - pub async fn cancel(&self, order_id: &str) -> Result { + pub async fn cancel(&self, order_id: &str) -> Result { let signer = self .signer .as_ref() @@ -1062,11 +1106,14 @@ impl ClobClient { )); } - Ok(response.json::().await?) + response + .json::() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {e}"), None)) } /// Cancel multiple orders - pub async fn cancel_orders(&self, order_ids: &[String]) -> Result { + pub async fn cancel_orders(&self, order_ids: &[String]) -> Result { let signer = self .signer .as_ref() @@ -1087,11 +1134,14 @@ impl ClobClient { )); } - Ok(response.json::().await?) + response + .json::() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {e}"), None)) } /// Cancel all orders - pub async fn cancel_all(&self) -> Result { + pub async fn cancel_all(&self) -> Result { let signer = self .signer .as_ref() @@ -1113,7 +1163,10 @@ impl ClobClient { )); } - Ok(response.json::().await?) + response + .json::() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {e}"), None)) } /// Get open orders with optional filtering @@ -2276,18 +2329,13 @@ impl ClobClient { // Re-export types from the canonical location in types.rs pub use crate::types::{ - ExtraOrderArgs, Market, MarketOrderArgs, MarketsResponse, MidpointResponse, NegRiskResponse, - OrderBookSummary, OrderSummary, PriceResponse, PricesHistoryInterval, PricesHistoryResponse, - Rewards, SpreadResponse, TickSizeResponse, Token, + CancelOrdersResponse as TypedCancelOrdersResponse, ClobMarketInfo as TypedClobMarketInfo, + CreateOrderOptions as TypedCreateOrderOptions, Market, MarketsResponse, MidpointResponse, + NegRiskResponse, OrderBookSummary, OrderSummary, PostOrderOptions as TypedPostOrderOptions, + PostOrderResponse as TypedPostOrderResponse, PriceResponse, PricesHistoryInterval, + PricesHistoryResponse, Rewards, SpreadResponse, TickSizeResponse, Token, }; -// Compatibility types that need to stay in client.rs -#[derive(Debug, Default)] -pub struct CreateOrderOptions { - pub tick_size: Option, - pub neg_risk: Option, -} - // Re-export for compatibility pub type PolyfillClient = ClobClient; @@ -2295,10 +2343,10 @@ pub type PolyfillClient = ClobClient; mod tests { use super::{ClobClient, OrderArgs as ClientOrderArgs}; use crate::types::{ - PricesHistoryInterval, RfqCreateQuote, RfqCreateRequest, RfqOrderExecutionRequest, - RfqQuotesParams, RfqRequestsParams, Side, + OrderType, PostOrderOptions, PricesHistoryInterval, RfqCreateQuote, RfqCreateRequest, + RfqOrderExecutionRequest, RfqQuotesParams, RfqRequestsParams, Side, SignedOrderRequest, }; - use crate::{ApiCredentials, PolyfillError}; + use crate::{ApiCredentials, ClientConfig, PolyfillError}; use mockito::{Matcher, Server}; use rust_decimal::Decimal; use serde_json::json; @@ -2310,11 +2358,16 @@ mod tests { } fn create_test_client_with_auth(base_url: &str) -> ClobClient { - ClobClient::with_l1_headers( - base_url, - "0x1234567890123456789012345678901234567890123456789012345678901234", - 137, - ) + ClobClient::from_config(ClientConfig { + base_url: base_url.to_string(), + chain: 137, + private_key: Some( + "0x1234567890123456789012345678901234567890123456789012345678901234" + .to_string(), + ), + ..ClientConfig::default() + }) + .expect("test auth client") } fn create_test_client_with_l2_auth(base_url: &str) -> ClobClient { @@ -2325,12 +2378,39 @@ mod tests { passphrase: "test_passphrase".to_string(), }; - ClobClient::with_l2_headers( - base_url, - "0x1234567890123456789012345678901234567890123456789012345678901234", - 137, - api_creds, - ) + ClobClient::from_config(ClientConfig { + base_url: base_url.to_string(), + chain: 137, + private_key: Some( + "0x1234567890123456789012345678901234567890123456789012345678901234" + .to_string(), + ), + api_credentials: Some(api_creds), + ..ClientConfig::default() + }) + .expect("test l2 auth client") + } + + fn sample_signed_order() -> SignedOrderRequest { + SignedOrderRequest { + salt: 42, + maker: "0x1111111111111111111111111111111111111111".to_string(), + signer: "0x2222222222222222222222222222222222222222".to_string(), + token_id: "123".to_string(), + maker_amount: "100".to_string(), + taker_amount: "250".to_string(), + expiration: "1900000000".to_string(), + side: "BUY".to_string(), + signature_type: 0, + timestamp: "1713916800000".to_string(), + metadata: + "0x0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + builder: + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + signature: "0xdeadbeef".to_string(), + } } #[tokio::test(flavor = "multi_thread")] @@ -2342,7 +2422,7 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn test_client_with_l1_headers() { + async fn test_client_from_config_with_signer() { let client = create_test_client_with_auth("https://test.example.com"); assert_eq!(client.base_url, "https://test.example.com"); assert!(client.signer.is_some()); @@ -2350,19 +2430,24 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn test_client_with_l2_headers() { + async fn test_client_from_config_with_api_credentials() { let api_creds = ApiCredentials { api_key: "test_key".to_string(), - secret: "test_secret".to_string(), + secret: "dGVzdF9zZWNyZXRfa2V5XzEyMzQ1".to_string(), passphrase: "test_passphrase".to_string(), }; - let client = ClobClient::with_l2_headers( - "https://test.example.com", - "0x1234567890123456789012345678901234567890123456789012345678901234", - 137, - api_creds.clone(), - ); + let client = ClobClient::from_config(ClientConfig { + base_url: "https://test.example.com".to_string(), + chain: 137, + private_key: Some( + "0x1234567890123456789012345678901234567890123456789012345678901234" + .to_string(), + ), + api_credentials: Some(api_creds.clone()), + ..ClientConfig::default() + }) + .expect("configured client"); assert_eq!(client.base_url, "https://test.example.com"); assert!(client.signer.is_some()); @@ -3027,6 +3112,175 @@ mod tests { assert_eq!(default_args.side, Side::BUY); } + #[tokio::test(flavor = "multi_thread")] + async fn test_get_clob_market_info_success() { + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/clob-markets/condition-1") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "gst":"ready", + "r":{}, + "t":[{"t":"123","o":"YES"},{"t":"456","o":"NO"}], + "mos":"5", + "mts":"0.01", + "mbf":"0", + "tbf":"15", + "rfqe":true, + "itode":false, + "ibce":false, + "nr":false, + "fd":{"r":"0.01","e":2,"to":false}, + "oas":"3600" + }"#, + ) + .create_async() + .await; + + let client = create_test_client(&server.url()); + let info = client.get_clob_market_info("condition-1").await.unwrap(); + + mock.assert_async().await; + assert_eq!(info.t.len(), 2); + assert_eq!(info.mos, Decimal::from_str("5").unwrap()); + assert_eq!(info.mts, Decimal::from_str("0.01").unwrap()); + assert_eq!(info.tbf, Decimal::from_str("15").unwrap()); + assert_eq!(info.fd.unwrap().e, 2); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_post_order_uses_v2_wire_shape_and_typed_response() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/order") + .match_body(Matcher::JsonString( + json!({ + "order": { + "salt": 42, + "maker": "0x1111111111111111111111111111111111111111", + "signer": "0x2222222222222222222222222222222222222222", + "tokenId": "123", + "makerAmount": "100", + "takerAmount": "250", + "expiration": "1900000000", + "side": "BUY", + "signatureType": 0, + "timestamp": "1713916800000", + "metadata": "0x0000000000000000000000000000000000000000000000000000000000000000", + "builder": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "signature": "0xdeadbeef" + }, + "owner": "test_key", + "orderType": "GTC", + "postOnly": true, + "deferExec": true + }) + .to_string(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "success":true, + "orderID":"order-1", + "status":"live", + "makingAmount":"100", + "takingAmount":"250", + "transactionsHashes":["0xabc"], + "tradeIds":["trade-1"], + "errorMsg":"" + }"#, + ) + .create_async() + .await; + + let client = create_test_client_with_l2_auth(&server.url()); + let response = client + .post_order( + sample_signed_order(), + Some(&PostOrderOptions { + order_type: OrderType::GTC, + post_only: true, + defer_exec: true, + }), + ) + .await + .unwrap(); + + mock.assert_async().await; + assert!(response.success); + assert_eq!(response.order_id, "order-1"); + assert_eq!(response.status, "live"); + assert_eq!(response.transactions_hashes, vec!["0xabc".to_string()]); + assert_eq!(response.trade_ids, vec!["trade-1".to_string()]); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_post_order_rejects_post_only_for_fak() { + let client = create_test_client_with_l2_auth("https://test.example.com"); + let err = client + .post_order( + sample_signed_order(), + Some(&PostOrderOptions { + order_type: OrderType::FAK, + post_only: true, + defer_exec: false, + }), + ) + .await + .unwrap_err(); + + assert!(matches!(err, PolyfillError::Validation { .. })); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_cancel_endpoints_parse_typed_responses() { + let mut server = Server::new_async().await; + let cancel_mock = server + .mock("DELETE", "/order") + .match_body(Matcher::JsonString(r#"{"orderID":"order-1"}"#.to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"canceled":["order-1"],"notCanceled":{}}"#) + .create_async() + .await; + let cancel_orders_mock = server + .mock("DELETE", "/orders") + .match_body(Matcher::JsonString(r#"["order-1","order-2"]"#.to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"canceled":["order-1"],"notCanceled":{"order-2":"already filled"}}"#) + .create_async() + .await; + let cancel_all_mock = server + .mock("DELETE", "/cancel-all") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"canceled":["order-9"],"notCanceled":{}}"#) + .create_async() + .await; + + let client = create_test_client_with_l2_auth(&server.url()); + let cancel = client.cancel("order-1").await.unwrap(); + let cancel_many = client + .cancel_orders(&["order-1".to_string(), "order-2".to_string()]) + .await + .unwrap(); + let cancel_all = client.cancel_all().await.unwrap(); + + cancel_mock.assert_async().await; + cancel_orders_mock.assert_async().await; + cancel_all_mock.assert_async().await; + assert_eq!(cancel.canceled, vec!["order-1".to_string()]); + assert_eq!( + cancel_many.not_canceled.get("order-2"), + Some(&"already filled".to_string()) + ); + assert_eq!(cancel_all.canceled, vec!["order-9".to_string()]); + } + #[tokio::test(flavor = "multi_thread")] async fn test_get_fee_rate_bps_success() { let mut server = Server::new_async().await; @@ -3035,7 +3289,7 @@ mod tests { .match_query(Matcher::UrlEncoded("token_id".into(), "123".into())) .with_status(200) .with_header("content-type", "application/json") - .with_body(r#"{"fee_rate_bps":1000}"#) + .with_body(r#"{"base_fee":1000}"#) .create_async() .await; diff --git a/src/connection_manager.rs b/src/connection_manager.rs index 301e087..4b3ff62 100644 --- a/src/connection_manager.rs +++ b/src/connection_manager.rs @@ -101,14 +101,14 @@ mod tests { #[tokio::test] async fn test_connection_manager_creation() { let client = reqwest::ClientBuilder::new().no_proxy().build().unwrap(); - let manager = ConnectionManager::new(client, "https://clob.polymarket.com".to_string()); + let manager = ConnectionManager::new(client, "https://clob-v2.polymarket.com".to_string()); assert!(!manager.is_running()); } #[tokio::test] async fn test_keepalive_start_stop() { let client = reqwest::ClientBuilder::new().no_proxy().build().unwrap(); - let manager = ConnectionManager::new(client, "https://clob.polymarket.com".to_string()); + let manager = ConnectionManager::new(client, "https://clob-v2.polymarket.com".to_string()); manager.start_keepalive(Duration::from_secs(30)).await; tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/src/dns_cache.rs b/src/dns_cache.rs index 657ebcb..48885ea 100644 --- a/src/dns_cache.rs +++ b/src/dns_cache.rs @@ -105,7 +105,7 @@ mod tests { #[ignore = "requires external DNS/network access"] async fn test_dns_cache_resolve() { let cache = DnsCache::new().await.unwrap(); - let ips = cache.resolve("clob.polymarket.com").await.unwrap(); + let ips = cache.resolve("clob-v2.polymarket.com").await.unwrap(); assert!(!ips.is_empty()); } @@ -113,7 +113,7 @@ mod tests { #[ignore = "requires external DNS/network access"] async fn test_dns_cache_prewarm() { let cache = DnsCache::new().await.unwrap(); - cache.prewarm("clob.polymarket.com").await.unwrap(); + cache.prewarm("clob-v2.polymarket.com").await.unwrap(); assert_eq!(cache.cache_size().await, 1); } @@ -121,7 +121,7 @@ mod tests { #[ignore = "requires external DNS/network access"] async fn test_dns_cache_clear() { let cache = DnsCache::new().await.unwrap(); - cache.prewarm("clob.polymarket.com").await.unwrap(); + cache.prewarm("clob-v2.polymarket.com").await.unwrap(); cache.clear().await; assert_eq!(cache.cache_size().await, 0); } diff --git a/src/lib.rs b/src/lib.rs index d7c5b08..574ee6d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,22 +13,27 @@ //! # Quick Start //! //! ```rust,no_run -//! use polyfill_rs::{ClobClient, OrderArgs, Side}; +//! use polyfill_rs::{ClientConfig, ClobClient, OrderArgs, Side}; //! use rust_decimal::Decimal; //! use std::str::FromStr; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { -//! // Create client (compatible with polymarket-rs-client) -//! let mut client = ClobClient::with_l1_headers( -//! "https://clob.polymarket.com", -//! "your_private_key", -//! 137, -//! ); +//! let bootstrap = ClobClient::from_config(ClientConfig { +//! base_url: "https://clob-v2.polymarket.com".to_string(), +//! chain: 137, +//! private_key: Some("your_private_key".to_string()), +//! ..ClientConfig::default() +//! })?; //! -//! // Get API credentials -//! let api_creds = client.create_or_derive_api_key(None).await.unwrap(); -//! client.set_api_creds(api_creds); +//! let api_creds = bootstrap.create_or_derive_api_key(None).await?; +//! let client = ClobClient::from_config(ClientConfig { +//! base_url: "https://clob-v2.polymarket.com".to_string(), +//! chain: 137, +//! private_key: Some("your_private_key".to_string()), +//! api_credentials: Some(api_creds), +//! ..ClientConfig::default() +//! })?; //! //! // Create and post order //! let order_args = OrderArgs::new( @@ -38,7 +43,7 @@ //! Side::BUY, //! ); //! -//! let result = client.create_and_post_order(&order_args).await.unwrap(); +//! let result = client.create_and_post_order(&order_args, None, None).await?; //! println!("Order posted: {:?}", result); //! //! Ok(()) @@ -54,7 +59,7 @@ //! #[tokio::main] //! async fn main() -> Result<(), Box> { //! // Create a basic client -//! let client = ClobClient::new("https://clob.polymarket.com"); +//! let client = ClobClient::new("https://clob-v2.polymarket.com"); //! //! // Get market data //! let markets = client.get_sampling_markets(None).await.unwrap(); @@ -72,7 +77,7 @@ use tracing::info; // Global constants pub const DEFAULT_CHAIN_ID: u64 = 137; // Polygon -pub const DEFAULT_BASE_URL: &str = "https://clob.polymarket.com"; +pub const DEFAULT_BASE_URL: &str = "https://clob-v2.polymarket.com"; pub const DEFAULT_TIMEOUT_SECS: u64 = 30; pub const DEFAULT_MAX_RETRIES: u32 = 3; pub const DEFAULT_RATE_LIMIT_RPS: u32 = 100; @@ -152,7 +157,7 @@ pub use crate::types::{ pub use crate::client::{ClobClient, PolyfillClient}; // Re-export compatibility types (for easy migration from polymarket-rs-client) -pub use crate::client::OrderArgs; +pub use crate::types::OrderArgs; // Re-export error types pub use crate::errors::{PolyfillError, Result}; diff --git a/src/orders.rs b/src/orders.rs index 15cde0c..0be1e0a 100644 --- a/src/orders.rs +++ b/src/orders.rs @@ -3,20 +3,23 @@ //! This module handles the complex process of creating and signing orders //! for the Polymarket CLOB, including EIP-712 signature generation. -use crate::auth::sign_order_message; -use crate::client::OrderArgs; +use crate::auth::{sign_order_message, SignedOrderMessage}; use crate::errors::{PolyfillError, Result}; -use crate::types::{ExtraOrderArgs, MarketOrderArgs, OrderOptions, Side, SignedOrderRequest}; -use alloy_primitives::{Address, U256}; +use crate::types::{CreateOrderOptions, MarketOrderArgs, OrderArgs, OrderType, Side, SignedOrderRequest}; +use alloy_primitives::{Address, B256, U256}; use alloy_signer_local::PrivateKeySigner; use rand::Rng; use rust_decimal::Decimal; +use rust_decimal::prelude::{FromPrimitive, ToPrimitive}; use rust_decimal::RoundingStrategy::{AwayFromZero, MidpointTowardZero, ToZero}; use std::collections::HashMap; use std::str::FromStr; use std::sync::LazyLock; use std::time::{SystemTime, UNIX_EPOCH}; +pub const BYTES32_ZERO: &str = + "0x0000000000000000000000000000000000000000000000000000000000000000"; + /// Signature types for orders #[derive(Copy, Clone)] pub enum SigType { @@ -91,13 +94,13 @@ static ROUNDING_CONFIG: LazyLock> = LazyLock::new( pub fn get_contract_config(chain_id: u64, neg_risk: bool) -> Option { match (chain_id, neg_risk) { (137, false) => Some(ContractConfig { - exchange: "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E".to_string(), - collateral: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(), + exchange: "0xE111180000d2663C0091e4f400237545B87B996B".to_string(), + collateral: "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB".to_string(), conditional_tokens: "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045".to_string(), }), (137, true) => Some(ContractConfig { - exchange: "0xC5d563A36AE78145C45a50134d48A1215220f80a".to_string(), - collateral: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(), + exchange: "0xe2222d279d744050d28e00520010520000310F59".to_string(), + collateral: "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB".to_string(), conditional_tokens: "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045".to_string(), }), _ => None, @@ -124,6 +127,90 @@ fn decimal_to_token_u32(amt: Decimal) -> u32 { amt.try_into().expect("Couldn't round decimal to integer") } +fn parse_round_config(tick_size: Decimal) -> Result<&'static RoundConfig> { + ROUNDING_CONFIG + .get(&tick_size) + .ok_or_else(|| PolyfillError::validation(format!("Unsupported tick size {tick_size}"))) +} + +fn validate_bytes32_hex(field: &str, value: &str) -> Result<()> { + if value == BYTES32_ZERO { + return Ok(()); + } + + if !value.starts_with("0x") { + return Err(PolyfillError::validation(format!( + "{field} must be a 0x-prefixed 32-byte hex string" + ))); + } + + if value.len() != 66 { + return Err(PolyfillError::validation(format!( + "{field} must be exactly 32 bytes (64 hex chars)" + ))); + } + + if !value + .as_bytes() + .iter() + .skip(2) + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err(PolyfillError::validation(format!( + "{field} must contain only hexadecimal characters" + ))); + } + + Ok(()) +} + +fn normalize_optional_bytes32(field: &str, value: Option<&str>) -> Result { + let value = value.unwrap_or(BYTES32_ZERO); + validate_bytes32_hex(field, value)?; + Ok(value.to_string()) +} + +pub fn adjust_buy_amount_for_fees( + amount: Decimal, + price: Decimal, + user_usdc_balance: Decimal, + fee_rate: Decimal, + fee_exponent: u32, + builder_taker_fee_rate_bps: Decimal, +) -> Result { + let price_f64 = price + .to_f64() + .ok_or_else(|| PolyfillError::validation(format!("Invalid price {price}")))?; + let amount_f64 = amount + .to_f64() + .ok_or_else(|| PolyfillError::validation(format!("Invalid amount {amount}")))?; + let user_balance_f64 = user_usdc_balance.to_f64().ok_or_else(|| { + PolyfillError::validation(format!("Invalid user_usdc_balance {user_usdc_balance}")) + })?; + let fee_rate_f64 = fee_rate + .to_f64() + .ok_or_else(|| PolyfillError::validation(format!("Invalid fee rate {fee_rate}")))?; + let builder_rate_f64 = builder_taker_fee_rate_bps + .to_f64() + .ok_or_else(|| PolyfillError::validation(format!( + "Invalid builder taker fee rate {builder_taker_fee_rate_bps}" + )))? + / 10_000.0; + + let platform_fee_rate = fee_rate_f64 * (price_f64 * (1.0 - price_f64)).powi(fee_exponent as i32); + let platform_fee = (amount_f64 / price_f64) * platform_fee_rate; + let total_cost = amount_f64 + platform_fee + amount_f64 * builder_rate_f64; + + let adjusted = if user_balance_f64 <= total_cost { + user_balance_f64 / (1.0 + platform_fee_rate / price_f64 + builder_rate_f64) + } else { + amount_f64 + }; + + Decimal::from_f64(adjusted) + .ok_or_else(|| PolyfillError::validation("Adjusted market buy amount is out of range")) +} + impl OrderBuilder { /// Create a new order builder pub fn new( @@ -193,20 +280,33 @@ impl OrderBuilder { /// Get order amounts for a market order fn get_market_order_amounts( &self, + side: Side, amount: Decimal, price: Decimal, round_config: &RoundConfig, ) -> (u32, u32) { - let raw_maker_amt = amount.round_dp_with_strategy(round_config.size, ToZero); let raw_price = price.round_dp_with_strategy(round_config.price, MidpointTowardZero); - let raw_taker_amt = raw_maker_amt / raw_price; - let raw_taker_amt = self.fix_amount_rounding(raw_taker_amt, round_config); + match side { + Side::BUY => { + let raw_maker_amt = amount.round_dp_with_strategy(round_config.size, ToZero); + let raw_taker_amt = self.fix_amount_rounding(raw_maker_amt / raw_price, round_config); - ( - decimal_to_token_u32(raw_maker_amt), - decimal_to_token_u32(raw_taker_amt), - ) + ( + decimal_to_token_u32(raw_maker_amt), + decimal_to_token_u32(raw_taker_amt), + ) + }, + Side::SELL => { + let raw_maker_amt = amount.round_dp_with_strategy(round_config.size, ToZero); + let raw_taker_amt = self.fix_amount_rounding(raw_maker_amt * raw_price, round_config); + + ( + decimal_to_token_u32(raw_maker_amt), + decimal_to_token_u32(raw_taker_amt), + ) + }, + } } /// Calculate market price from order book levels @@ -214,23 +314,33 @@ impl OrderBuilder { &self, positions: &[crate::types::BookLevel], amount_to_match: Decimal, + side: Side, + order_type: OrderType, ) -> Result { let mut sum = Decimal::ZERO; + let mut last_price = None; for level in positions { - sum += level.size * level.price; + sum += match side { + Side::BUY => level.size * level.price, + Side::SELL => level.size, + }; + last_price = Some(level.price); if sum >= amount_to_match { return Ok(level.price); } } - Err(PolyfillError::order( - format!( - "Not enough liquidity to create market order with amount {}", - amount_to_match - ), - crate::errors::OrderErrorKind::InsufficientBalance, - )) + match (order_type, last_price) { + (OrderType::FAK, Some(price)) => Ok(price), + _ => Err(PolyfillError::order( + format!( + "Not enough liquidity to create market order with amount {}", + amount_to_match + ), + crate::errors::OrderErrorKind::InsufficientBalance, + )), + } } /// Create a market order @@ -239,15 +349,21 @@ impl OrderBuilder { chain_id: u64, order_args: &MarketOrderArgs, price: Decimal, - extras: &ExtraOrderArgs, - options: &OrderOptions, + options: &CreateOrderOptions, ) -> Result { - let tick_size = options - .tick_size - .ok_or_else(|| PolyfillError::validation("Cannot create order without tick size"))?; + if !matches!(order_args.order_type, OrderType::FOK | OrderType::FAK) { + return Err(PolyfillError::validation( + "Market orders only support FOK and FAK order types", + )); + } + + let tick_size = options.tick_size.ok_or_else(|| { + PolyfillError::validation("Cannot create order without tick size") + })?; + let round_config = parse_round_config(tick_size)?; let (maker_amount, taker_amount) = - self.get_market_order_amounts(order_args.amount, price, &ROUNDING_CONFIG[&tick_size]); + self.get_market_order_amounts(order_args.side, order_args.amount, price, round_config); let neg_risk = options .neg_risk @@ -262,13 +378,14 @@ impl OrderBuilder { self.build_signed_order( order_args.token_id.clone(), - Side::BUY, + order_args.side, chain_id, exchange_address, maker_amount, taker_amount, 0, - extras, + order_args.builder_code.as_deref(), + order_args.metadata.as_deref(), ) } @@ -277,19 +394,18 @@ impl OrderBuilder { &self, chain_id: u64, order_args: &OrderArgs, - expiration: u64, - extras: &ExtraOrderArgs, - options: &OrderOptions, + options: &CreateOrderOptions, ) -> Result { - let tick_size = options - .tick_size - .ok_or_else(|| PolyfillError::validation("Cannot create order without tick size"))?; + let tick_size = options.tick_size.ok_or_else(|| { + PolyfillError::validation("Cannot create order without tick size") + })?; + let round_config = parse_round_config(tick_size)?; let (maker_amount, taker_amount) = self.get_order_amounts( order_args.side, order_args.size, order_args.price, - &ROUNDING_CONFIG[&tick_size], + round_config, ); let neg_risk = options @@ -310,8 +426,9 @@ impl OrderBuilder { exchange_address, maker_amount, taker_amount, - expiration, - extras, + order_args.expiration.unwrap_or(0), + order_args.builder_code.as_deref(), + order_args.metadata.as_deref(), ) } @@ -326,28 +443,36 @@ impl OrderBuilder { maker_amount: u32, taker_amount: u32, expiration: u64, - extras: &ExtraOrderArgs, + builder_code: Option<&str>, + metadata: Option<&str>, ) -> Result { let seed = generate_seed(); - let taker_address = Address::from_str(&extras.taker) - .map_err(|e| PolyfillError::validation(format!("Invalid taker address: {}", e)))?; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_millis(); let u256_token_id = U256::from_str_radix(&token_id, 10) .map_err(|e| PolyfillError::validation(format!("Incorrect tokenId format: {}", e)))?; + let builder = normalize_optional_bytes32("builder_code", builder_code)?; + let metadata = normalize_optional_bytes32("metadata", metadata)?; - let order = crate::auth::Order { + let order = SignedOrderMessage { salt: U256::from(seed), maker: self.funder, signer: self.signer.address(), - taker: taker_address, - tokenId: u256_token_id, - makerAmount: U256::from(maker_amount), - takerAmount: U256::from(taker_amount), - expiration: U256::from(expiration), - nonce: extras.nonce, - feeRateBps: U256::from(extras.fee_rate_bps), + token_id: u256_token_id, + maker_amount: U256::from(maker_amount), + taker_amount: U256::from(taker_amount), side: side as u8, - signatureType: self.sig_type as u8, + signature_type: self.sig_type as u8, + timestamp: U256::from(timestamp), + metadata: B256::from_str(&metadata).map_err(|e| { + PolyfillError::validation(format!("Invalid metadata bytes32 value: {e}")) + })?, + builder: B256::from_str(&builder).map_err(|e| { + PolyfillError::validation(format!("Invalid builder_code bytes32 value: {e}")) + })?, }; let signature = sign_order_message(&self.signer, order, chain_id, exchange)?; @@ -356,15 +481,15 @@ impl OrderBuilder { salt: seed, maker: self.funder.to_checksum(None), signer: self.signer.address().to_checksum(None), - taker: taker_address.to_checksum(None), token_id, maker_amount: maker_amount.to_string(), taker_amount: taker_amount.to_string(), expiration: expiration.to_string(), - nonce: extras.nonce.to_string(), - fee_rate_bps: extras.fee_rate_bps.to_string(), side: side.as_str().to_string(), signature_type: self.sig_type as u8, + timestamp: timestamp.to_string(), + metadata, + builder, signature, }) } @@ -373,6 +498,16 @@ impl OrderBuilder { #[cfg(test)] mod tests { use super::*; + use alloy_signer_local::PrivateKeySigner; + use serde_json::Value; + + fn test_builder() -> OrderBuilder { + let signer: PrivateKeySigner = + "0x1234567890123456789012345678901234567890123456789012345678901234" + .parse() + .expect("valid private key"); + OrderBuilder::new(signer, None, None) + } #[test] fn test_decimal_to_token_u32() { @@ -405,18 +540,185 @@ mod tests { #[test] fn test_get_contract_config() { // Test Polygon mainnet - let config = get_contract_config(137, false); - assert!(config.is_some()); + let config = get_contract_config(137, false).expect("polygon config"); + assert_eq!(config.exchange, "0xE111180000d2663C0091e4f400237545B87B996B"); + assert_eq!(config.collateral, "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"); + assert_eq!( + config.conditional_tokens, + "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" + ); // Test with neg risk - let config_neg = get_contract_config(137, true); - assert!(config_neg.is_some()); + let config_neg = get_contract_config(137, true).expect("neg risk polygon config"); + assert_eq!( + config_neg.exchange, + "0xe2222d279d744050d28e00520010520000310F59" + ); + assert_eq!( + config_neg.collateral, + "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" + ); // Test unsupported chain let config_unsupported = get_contract_config(999, false); assert!(config_unsupported.is_none()); } + #[test] + fn test_normalize_optional_bytes32_defaults_to_zero() { + assert_eq!( + normalize_optional_bytes32("builder_code", None).unwrap(), + BYTES32_ZERO + ); + } + + #[test] + fn test_normalize_optional_bytes32_rejects_invalid_hex() { + let err = normalize_optional_bytes32("metadata", Some("deadbeef")).unwrap_err(); + assert!(matches!(err, PolyfillError::Validation { .. })); + } + + #[test] + fn test_create_order_serializes_v2_fields_without_legacy_fields() { + let builder = test_builder(); + let order = builder + .create_order( + 137, + &OrderArgs { + token_id: "123456".to_string(), + price: Decimal::from_str("0.45").unwrap(), + size: Decimal::from_str("12.34").unwrap(), + side: Side::BUY, + expiration: Some(1_900_000_000), + builder_code: Some(BYTES32_ZERO.to_string()), + metadata: None, + }, + &CreateOrderOptions { + tick_size: Some(Decimal::from_str("0.01").unwrap()), + neg_risk: Some(false), + }, + ) + .unwrap(); + + let serialized = serde_json::to_value(&order).unwrap(); + let object = serialized.as_object().unwrap(); + assert!(object.contains_key("timestamp")); + assert!(object.contains_key("metadata")); + assert!(object.contains_key("builder")); + assert!(object.contains_key("expiration")); + assert!(!object.contains_key("taker")); + assert!(!object.contains_key("nonce")); + assert!(!object.contains_key("feeRateBps")); + assert_eq!(order.builder, BYTES32_ZERO); + assert_eq!(order.metadata, BYTES32_ZERO); + } + + #[test] + fn test_create_market_order_supports_fak() { + let builder = test_builder(); + let order = builder + .create_market_order( + 137, + &MarketOrderArgs { + token_id: "123456".to_string(), + amount: Decimal::from_str("10.0").unwrap(), + side: Side::BUY, + order_type: OrderType::FAK, + price_limit: None, + user_usdc_balance: None, + builder_code: None, + metadata: None, + }, + Decimal::from_str("0.25").unwrap(), + &CreateOrderOptions { + tick_size: Some(Decimal::from_str("0.01").unwrap()), + neg_risk: Some(false), + }, + ) + .unwrap(); + + assert_eq!(order.side, "BUY"); + assert!(!order.timestamp.is_empty()); + } + + #[test] + fn test_market_order_amounts_differ_for_buy_and_sell() { + let builder = test_builder(); + let round_config = parse_round_config(Decimal::from_str("0.01").unwrap()).unwrap(); + + let (buy_maker, buy_taker) = builder.get_market_order_amounts( + Side::BUY, + Decimal::from_str("10").unwrap(), + Decimal::from_str("0.25").unwrap(), + round_config, + ); + let (sell_maker, sell_taker) = builder.get_market_order_amounts( + Side::SELL, + Decimal::from_str("10").unwrap(), + Decimal::from_str("0.25").unwrap(), + round_config, + ); + + assert_eq!(buy_maker, 10_000_000); + assert_eq!(buy_taker, 40_000_000); + assert_eq!(sell_maker, 10_000_000); + assert_eq!(sell_taker, 2_500_000); + } + + #[test] + fn test_calculate_market_price_returns_last_level_for_fak() { + let builder = test_builder(); + let levels = vec![ + crate::types::BookLevel { + price: Decimal::from_str("0.40").unwrap(), + size: Decimal::from_str("2.0").unwrap(), + }, + crate::types::BookLevel { + price: Decimal::from_str("0.45").unwrap(), + size: Decimal::from_str("1.0").unwrap(), + }, + ]; + + let price = builder + .calculate_market_price( + &levels, + Decimal::from_str("10.0").unwrap(), + Side::SELL, + OrderType::FAK, + ) + .unwrap(); + assert_eq!(price, Decimal::from_str("0.45").unwrap()); + } + + #[test] + fn test_signed_order_json_uses_camel_case_wire_shape() { + let builder = test_builder(); + let order = builder + .create_order( + 137, + &OrderArgs { + token_id: "123456".to_string(), + price: Decimal::from_str("0.55").unwrap(), + size: Decimal::from_str("5.0").unwrap(), + side: Side::SELL, + expiration: Some(1_900_000_000), + builder_code: None, + metadata: Some(BYTES32_ZERO.to_string()), + }, + &CreateOrderOptions { + tick_size: Some(Decimal::from_str("0.01").unwrap()), + neg_risk: Some(true), + }, + ) + .unwrap(); + + let json = serde_json::to_value(order).unwrap(); + assert!(matches!(json.get("tokenId"), Some(Value::String(_)))); + assert!(matches!(json.get("makerAmount"), Some(Value::String(_)))); + assert!(matches!(json.get("takerAmount"), Some(Value::String(_)))); + assert!(matches!(json.get("signatureType"), Some(Value::Number(_)))); + } + #[test] fn test_seed_generation_uniqueness() { let mut seeds = std::collections::HashSet::new(); diff --git a/src/types.rs b/src/types.rs index eaecc24..918b32a 100644 --- a/src/types.rs +++ b/src/types.rs @@ -3,7 +3,7 @@ //! This module defines all the stable public types used throughout the client. //! These types are optimized for latency-sensitive trading environments. -use alloy_primitives::{Address, U256}; +use alloy_primitives::Address; use chrono::{DateTime, Utc}; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; @@ -215,6 +215,7 @@ pub enum OrderType { GTC, FOK, GTD, + FAK, } impl OrderType { @@ -223,6 +224,7 @@ impl OrderType { OrderType::GTC => "GTC", OrderType::FOK => "FOK", OrderType::GTD => "GTD", + OrderType::FAK => "FAK", } } } @@ -494,37 +496,97 @@ pub struct ApiCredentials { pub passphrase: String, } -/// Configuration for order creation -#[derive(Debug, Clone)] -pub struct OrderOptions { - pub tick_size: Option, - pub neg_risk: Option, - pub fee_rate_bps: Option, +/// Limit order arguments for V2 order creation. +#[derive(Debug, Clone, PartialEq)] +pub struct OrderArgs { + pub token_id: String, + pub price: Decimal, + pub size: Decimal, + pub side: Side, + pub expiration: Option, + pub builder_code: Option, + pub metadata: Option, } -/// Extra arguments for order creation -#[derive(Debug, Clone)] -pub struct ExtraOrderArgs { - pub fee_rate_bps: u32, - pub nonce: U256, - pub taker: String, -} - -impl Default for ExtraOrderArgs { - fn default() -> Self { +impl OrderArgs { + pub fn new(token_id: &str, price: Decimal, size: Decimal, side: Side) -> Self { Self { - fee_rate_bps: 0, - nonce: U256::ZERO, - taker: "0x0000000000000000000000000000000000000000".to_string(), + token_id: token_id.to_string(), + price, + size, + side, + expiration: None, + builder_code: None, + metadata: None, } } } -/// Market order arguments -#[derive(Debug, Clone)] +impl Default for OrderArgs { + fn default() -> Self { + Self { + token_id: String::new(), + price: Decimal::ZERO, + size: Decimal::ZERO, + side: Side::BUY, + expiration: None, + builder_code: None, + metadata: None, + } + } +} + +/// Market order arguments for V2 order creation. +#[derive(Debug, Clone, PartialEq)] pub struct MarketOrderArgs { pub token_id: String, pub amount: Decimal, + pub side: Side, + pub order_type: OrderType, + pub price_limit: Option, + pub user_usdc_balance: Option, + pub builder_code: Option, + pub metadata: Option, +} + +impl MarketOrderArgs { + pub fn new(token_id: &str, amount: Decimal, side: Side, order_type: OrderType) -> Self { + Self { + token_id: token_id.to_string(), + amount, + side, + order_type, + price_limit: None, + user_usdc_balance: None, + builder_code: None, + metadata: None, + } + } +} + +/// Options used while constructing an order. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct CreateOrderOptions { + pub tick_size: Option, + pub neg_risk: Option, +} + +/// Options used while posting a signed order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PostOrderOptions { + pub order_type: OrderType, + pub post_only: bool, + pub defer_exec: bool, +} + +impl Default for PostOrderOptions { + fn default() -> Self { + Self { + order_type: OrderType::GTC, + post_only: false, + defer_exec: false, + } + } } /// Signed order request ready for submission @@ -534,15 +596,15 @@ pub struct SignedOrderRequest { pub salt: u64, pub maker: String, pub signer: String, - pub taker: String, pub token_id: String, pub maker_amount: String, pub taker_amount: String, pub expiration: String, - pub nonce: String, - pub fee_rate_bps: String, pub side: String, pub signature_type: u8, + pub timestamp: String, + pub metadata: String, + pub builder: String, pub signature: String, } @@ -553,18 +615,95 @@ pub struct PostOrder { pub order: SignedOrderRequest, pub owner: String, pub order_type: OrderType, + pub post_only: bool, + pub defer_exec: bool, } impl PostOrder { - pub fn new(order: SignedOrderRequest, owner: String, order_type: OrderType) -> Self { + pub fn new(order: SignedOrderRequest, owner: String, options: PostOrderOptions) -> Self { Self { order, owner, - order_type, + order_type: options.order_type, + post_only: options.post_only, + defer_exec: options.defer_exec, } } } +/// Typed response from `POST /order`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PostOrderResponse { + pub success: bool, + #[serde(rename = "orderID")] + pub order_id: String, + pub status: String, + pub making_amount: String, + pub taking_amount: String, + #[serde(default)] + pub transactions_hashes: Vec, + #[serde(default)] + pub trade_ids: Vec, + #[serde(default)] + pub error_msg: String, +} + +/// Typed response from cancel endpoints. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CancelOrdersResponse { + #[serde(default)] + pub canceled: Vec, + #[serde(default, alias = "not_canceled")] + pub not_canceled: std::collections::HashMap, +} + +/// Token info returned by `GET /clob-markets/{condition_id}`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ClobTokenInfo { + pub t: String, + pub o: String, +} + +/// Fee details returned by `GET /clob-markets/{condition_id}`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ClobFeeDetails { + pub r: Decimal, + pub e: u32, + #[serde(default)] + pub to: bool, +} + +/// CLOB market info returned by `GET /clob-markets/{condition_id}`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ClobMarketInfo { + #[serde(default)] + pub gst: Option, + pub r: serde_json::Value, + pub t: Vec, + #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + pub mos: Decimal, + #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + pub mts: Decimal, + #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + pub mbf: Decimal, + #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + pub tbf: Decimal, + #[serde(default)] + pub rfqe: bool, + #[serde(default)] + pub itode: bool, + #[serde(default)] + pub ibce: bool, + #[serde(default)] + pub nr: Option, + #[serde(default)] + pub fd: Option, + #[serde(default, deserialize_with = "crate::decode::deserializers::optional_number_from_string")] + pub oas: Option, +} + /// Market information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Market { @@ -630,15 +769,13 @@ pub struct ClientConfig { /// Base URL for the API pub base_url: String, /// Chain ID for the network - pub chain_id: u64, + pub chain: u64, /// Private key for signing (optional) pub private_key: Option, /// API credentials (optional) pub api_credentials: Option, - /// Maximum slippage tolerance - pub max_slippage: Option, - /// Fee rate in basis points - pub fee_rate: Option, + /// Builder code applied to orders when none is specified on the order itself. + pub builder_code: Option, /// Request timeout pub timeout: Option, /// Maximum number of connections @@ -648,14 +785,13 @@ pub struct ClientConfig { impl Default for ClientConfig { fn default() -> Self { Self { - base_url: "https://clob.polymarket.com".to_string(), - chain_id: 137, // Polygon mainnet + base_url: "https://clob-v2.polymarket.com".to_string(), + chain: 137, // Polygon mainnet private_key: None, api_credentials: None, + builder_code: None, timeout: Some(std::time::Duration::from_secs(30)), max_connections: Some(100), - max_slippage: None, - fee_rate: None, } } } @@ -1374,7 +1510,8 @@ pub struct Rewards { /// Fee rate in basis points for a given token. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FeeRateResponse { - pub fee_rate_bps: u32, + #[serde(alias = "fee_rate_bps")] + pub base_fee: u32, } /// Create RFQ request (Requester). @@ -1655,5 +1792,3 @@ pub type Result = std::result::Result; // Type aliases for 100% compatibility with baseline implementation pub type ApiCreds = ApiCredentials; -pub type CreateOrderOptions = OrderOptions; -pub type OrderArgs = OrderRequest; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 1416f38..06b0d8a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,6 +1,6 @@ //! Common utilities for integration tests -use polyfill_rs::{ClobClient, Result}; +use polyfill_rs::{ClientConfig, ClobClient, Result}; use std::env; use std::time::Duration; @@ -8,7 +8,7 @@ use std::time::Duration; #[derive(Debug, Clone)] pub struct TestConfig { pub host: String, - pub chain_id: u64, + pub chain: u64, pub private_key: Option, pub api_key: Option, pub api_secret: Option, @@ -19,8 +19,9 @@ pub struct TestConfig { impl Default for TestConfig { fn default() -> Self { Self { - host: env::var("POLYMARKET_HOST").unwrap_or_else(|_| "https://clob.polymarket.com".to_string()), - chain_id: env::var("POLYMARKET_CHAIN_ID") + host: env::var("POLYMARKET_HOST") + .unwrap_or_else(|_| "https://clob-v2.polymarket.com".to_string()), + chain: env::var("POLYMARKET_CHAIN_ID") .unwrap_or_else(|_| "137".to_string()) .parse() .unwrap_or(137), @@ -57,17 +58,26 @@ impl TestConfig { /// Create an authenticated client for testing pub fn create_auth_client(&self) -> Result { - let private_key = self.private_key.as_ref() - .ok_or_else(|| polyfill_rs::PolyfillError::auth("No private key provided", polyfill_rs::errors::AuthErrorKind::InvalidCredentials))?; - - Ok(ClobClient::with_l1_headers(&self.host, private_key, self.chain_id)) + let private_key = self.private_key.as_ref().ok_or_else(|| { + polyfill_rs::PolyfillError::auth( + "No private key provided", + polyfill_rs::errors::AuthErrorKind::InvalidCredentials, + ) + })?; + + ClobClient::from_config(ClientConfig { + base_url: self.host.clone(), + chain: self.chain, + private_key: Some(private_key.clone()), + ..ClientConfig::default() + }) } /// Print test configuration (without sensitive data) pub fn print_config(&self) { println!("Test Configuration:"); println!(" Host: {}", self.host); - println!(" Chain ID: {}", self.chain_id); + println!(" Chain ID: {}", self.chain); println!(" Has Auth: {}", self.has_auth()); println!(" Has API Creds: {}", self.has_api_creds()); println!(" Timeout: {:?}", self.test_timeout); diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index b15ebff..1332fbf 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -2,11 +2,11 @@ // These tests hit the real Polymarket API and are ignored by default // Run with: cargo test --test integration_tests -- --ignored --test-threads=1 -use polyfill_rs::{ClobClient, OrderArgs, Side}; +use polyfill_rs::{ClientConfig, ClobClient, OrderArgs, Side}; use rust_decimal_macros::dec; use std::env; -const HOST: &str = "https://clob.polymarket.com"; +const HOST: &str = "https://clob-v2.polymarket.com"; const CHAIN_ID: u64 = 137; fn load_env_vars() -> (String, Option, Option, Option) { @@ -21,12 +21,33 @@ fn load_env_vars() -> (String, Option, Option, Option) { (private_key, api_key, api_secret, api_passphrase) } +fn bootstrap_client(private_key: &str) -> ClobClient { + ClobClient::from_config(ClientConfig { + base_url: HOST.to_string(), + chain: CHAIN_ID, + private_key: Some(private_key.to_string()), + ..ClientConfig::default() + }) + .expect("failed to build bootstrap client") +} + +fn authenticated_client(private_key: String, api_credentials: polyfill_rs::ApiCredentials) -> ClobClient { + ClobClient::from_config(ClientConfig { + base_url: HOST.to_string(), + chain: CHAIN_ID, + private_key: Some(private_key), + api_credentials: Some(api_credentials), + ..ClientConfig::default() + }) + .expect("failed to build authenticated client") +} + #[tokio::test(flavor = "multi_thread")] #[ignore] async fn test_real_api_create_derive_api_key() { let (private_key, _, _, _) = load_env_vars(); - let client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID); + let client = bootstrap_client(&private_key); // Test creating/deriving API key let result = client.create_or_derive_api_key(None).await; @@ -50,15 +71,15 @@ async fn test_real_api_authenticated_order_flow() { let (private_key, _, _, _) = load_env_vars(); // Initialize client with L1 headers - let mut client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID); + let bootstrap = bootstrap_client(&private_key); // Step 1: Create/derive API credentials println!("Step 1: Creating/deriving API credentials..."); - let api_creds = client + let api_creds = bootstrap .create_or_derive_api_key(None) .await .expect("Failed to create/derive API key"); - client.set_api_creds(api_creds); + let client = authenticated_client(private_key, api_creds); println!("PASS: API credentials set"); // Step 2: Get a valid token_id from active markets @@ -102,9 +123,12 @@ async fn test_real_api_authenticated_order_flow() { price: order_price, size: dec!(1.0), // Small size (auth is the thing we're testing here) side, + expiration: None, + builder_code: None, + metadata: None, }; - let post_result = client.create_and_post_order(&order_args).await; + let post_result = client.create_and_post_order(&order_args, None, None).await; // This is the critical test - did we get past the 401 error? match &post_result { @@ -112,9 +136,9 @@ async fn test_real_api_authenticated_order_flow() { println!("PASS: Order posted successfully!"); // Step 5: Cancel the order - if let Some(order_id) = response.get("orderID").and_then(|v| v.as_str()) { - println!("Step 5: Canceling order {}...", order_id); - let cancel_result = client.cancel(order_id).await; + if !response.order_id.is_empty() { + println!("Step 5: Canceling order {}...", response.order_id); + let cancel_result = client.cancel(&response.order_id).await; assert!( cancel_result.is_ok(), "Failed to cancel order: {:?}", @@ -156,12 +180,12 @@ async fn test_real_api_authenticated_order_flow() { async fn test_real_api_get_orders() { let (private_key, _, _, _) = load_env_vars(); - let mut client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID); - let api_creds = client + let bootstrap = bootstrap_client(&private_key); + let api_creds = bootstrap .create_or_derive_api_key(None) .await .expect("Failed to create/derive API key"); - client.set_api_creds(api_creds); + let client = authenticated_client(private_key, api_creds); println!("Testing get_orders..."); let result = client.get_orders(None, None).await; @@ -186,12 +210,12 @@ async fn test_real_api_get_orders() { async fn test_real_api_get_trades() { let (private_key, _, _, _) = load_env_vars(); - let mut client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID); - let api_creds = client + let bootstrap = bootstrap_client(&private_key); + let api_creds = bootstrap .create_or_derive_api_key(None) .await .expect("Failed to create/derive API key"); - client.set_api_creds(api_creds); + let client = authenticated_client(private_key, api_creds); println!("Testing get_trades..."); let result = client.get_trades(None, None).await; @@ -215,12 +239,12 @@ async fn test_real_api_get_trades() { async fn test_real_api_get_balance_allowance() { let (private_key, _, _, _) = load_env_vars(); - let mut client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID); - let api_creds = client + let bootstrap = bootstrap_client(&private_key); + let api_creds = bootstrap .create_or_derive_api_key(None) .await .expect("Failed to create/derive API key"); - client.set_api_creds(api_creds); + let client = authenticated_client(private_key, api_creds); println!("Testing get_balance_allowance..."); @@ -260,12 +284,12 @@ async fn test_real_api_get_balance_allowance() { async fn test_real_api_get_api_keys() { let (private_key, _, _, _) = load_env_vars(); - let mut client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID); - let api_creds = client + let bootstrap = bootstrap_client(&private_key); + let api_creds = bootstrap .create_or_derive_api_key(None) .await .expect("Failed to create/derive API key"); - client.set_api_creds(api_creds); + let client = authenticated_client(private_key, api_creds); println!("Testing get_api_keys..."); let result = client.get_api_keys().await; @@ -290,12 +314,12 @@ async fn test_real_api_get_api_keys() { async fn test_real_api_get_notifications() { let (private_key, _, _, _) = load_env_vars(); - let mut client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID); - let api_creds = client + let bootstrap = bootstrap_client(&private_key); + let api_creds = bootstrap .create_or_derive_api_key(None) .await .expect("Failed to create/derive API key"); - client.set_api_creds(api_creds); + let client = authenticated_client(private_key, api_creds); println!("Testing get_notifications..."); let result = client.get_notifications().await; @@ -320,7 +344,7 @@ async fn test_real_api_get_notifications() { async fn test_real_api_market_data_endpoints() { let (private_key, _, _, _) = load_env_vars(); - let client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID); + let client = bootstrap_client(&private_key); println!("Testing market data endpoints (no auth required)..."); @@ -387,7 +411,7 @@ async fn test_real_api_market_data_endpoints() { async fn test_real_api_batch_endpoints() { let (private_key, _, _, _) = load_env_vars(); - let client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID); + let client = bootstrap_client(&private_key); println!("Testing batch endpoints..."); diff --git a/tests/order_posting_test.rs b/tests/order_posting_test.rs index f41deeb..300aed2 100644 --- a/tests/order_posting_test.rs +++ b/tests/order_posting_test.rs @@ -1,5 +1,5 @@ // Test order posting - the critical endpoint that had the 401 bug -use polyfill_rs::{ClobClient, OrderArgs, Side}; +use polyfill_rs::{ClientConfig, ClobClient, OrderArgs, Side}; use rust_decimal::Decimal; use std::env; use std::str::FromStr; @@ -12,14 +12,27 @@ async fn test_post_order_authentication() { let private_key = env::var("POLYMARKET_PRIVATE_KEY").expect("POLYMARKET_PRIVATE_KEY must be set in .env"); - let mut client = ClobClient::with_l1_headers("https://clob.polymarket.com", &private_key, 137); + let bootstrap = ClobClient::from_config(ClientConfig { + base_url: "https://clob-v2.polymarket.com".to_string(), + chain: 137, + private_key: Some(private_key.clone()), + ..ClientConfig::default() + }) + .expect("failed to build bootstrap client"); println!("Step 1: Creating API credentials..."); - let creds = client + let creds = bootstrap .create_or_derive_api_key(None) .await .expect("Failed to create API key"); - client.set_api_creds(creds); + let client = ClobClient::from_config(ClientConfig { + base_url: "https://clob-v2.polymarket.com".to_string(), + chain: 137, + private_key: Some(private_key), + api_credentials: Some(creds), + ..ClientConfig::default() + }) + .expect("failed to build authenticated client"); println!("API credentials set"); // Use a well-known token ID (we'll use an extreme price so it won't fill) @@ -31,9 +44,12 @@ async fn test_post_order_authentication() { price: Decimal::from_str("0.01").unwrap(), // Very low price, won't fill size: Decimal::from_str("1.0").unwrap(), side: Side::BUY, + expiration: None, + builder_code: None, + metadata: None, }; - let result = client.create_and_post_order(&order_args).await; + let result = client.create_and_post_order(&order_args, None, None).await; match result { Ok(response) => { @@ -41,9 +57,9 @@ async fn test_post_order_authentication() { println!(" Response: {:?}", response); // Try to cancel it if we got an order ID - if let Some(order_id) = response.get("orderID").and_then(|v| v.as_str()) { + if !response.order_id.is_empty() { println!("\nStep 3: Canceling order..."); - match client.cancel(order_id).await { + match client.cancel(&response.order_id).await { Ok(_) => println!("Order canceled successfully"), Err(e) => println!("Cancel failed (order might have expired): {:?}", e), } diff --git a/tests/prices_history_integration_tests.rs b/tests/prices_history_integration_tests.rs index 189ebed..0868864 100644 --- a/tests/prices_history_integration_tests.rs +++ b/tests/prices_history_integration_tests.rs @@ -7,7 +7,7 @@ use polyfill_rs::{ClobClient, PricesHistoryInterval}; -const HOST: &str = "https://clob.polymarket.com"; +const HOST: &str = "https://clob-v2.polymarket.com"; #[tokio::test(flavor = "multi_thread")] #[ignore] diff --git a/tests/simple_auth_test.rs b/tests/simple_auth_test.rs index ad9481f..ed33628 100644 --- a/tests/simple_auth_test.rs +++ b/tests/simple_auth_test.rs @@ -1,7 +1,17 @@ // Simple authentication test to verify HMAC works -use polyfill_rs::ClobClient; +use polyfill_rs::{ClientConfig, ClobClient}; use std::env; +fn build_bootstrap_client(private_key: &str) -> ClobClient { + ClobClient::from_config(ClientConfig { + base_url: "https://clob-v2.polymarket.com".to_string(), + chain: 137, + private_key: Some(private_key.to_string()), + ..ClientConfig::default() + }) + .expect("failed to build bootstrap client") +} + #[tokio::test(flavor = "multi_thread")] #[ignore] async fn test_create_api_key_simple() { @@ -10,16 +20,23 @@ async fn test_create_api_key_simple() { let private_key = env::var("POLYMARKET_PRIVATE_KEY").expect("POLYMARKET_PRIVATE_KEY must be set in .env"); - let mut client = ClobClient::with_l1_headers("https://clob.polymarket.com", &private_key, 137); + let bootstrap = build_bootstrap_client(&private_key); println!("Step 1: Creating/deriving API key..."); - let result = client.create_or_derive_api_key(None).await; + let result = bootstrap.create_or_derive_api_key(None).await; match result { Ok(creds) => { println!("Successfully created/derived API key"); println!(" API Key created (len={})", creds.api_key.len()); - client.set_api_creds(creds); + let client = ClobClient::from_config(ClientConfig { + base_url: "https://clob-v2.polymarket.com".to_string(), + chain: 137, + private_key: Some(private_key), + api_credentials: Some(creds), + ..ClientConfig::default() + }) + .expect("failed to build authenticated client"); // Now try to get orders (requires auth) println!("\nStep 2: Testing authenticated endpoint (get_orders)..."); @@ -53,13 +70,20 @@ async fn test_get_api_keys() { let private_key = env::var("POLYMARKET_PRIVATE_KEY").expect("POLYMARKET_PRIVATE_KEY must be set in .env"); - let mut client = ClobClient::with_l1_headers("https://clob.polymarket.com", &private_key, 137); + let bootstrap = build_bootstrap_client(&private_key); - let creds = client + let creds = bootstrap .create_or_derive_api_key(None) .await .expect("Failed to create API key"); - client.set_api_creds(creds); + let client = ClobClient::from_config(ClientConfig { + base_url: "https://clob-v2.polymarket.com".to_string(), + chain: 137, + private_key: Some(private_key), + api_credentials: Some(creds), + ..ClientConfig::default() + }) + .expect("failed to build authenticated client"); println!("Testing get_api_keys (requires HMAC auth)..."); let result = client.get_api_keys().await; @@ -87,13 +111,20 @@ async fn test_get_trades() { let private_key = env::var("POLYMARKET_PRIVATE_KEY").expect("POLYMARKET_PRIVATE_KEY must be set in .env"); - let mut client = ClobClient::with_l1_headers("https://clob.polymarket.com", &private_key, 137); + let bootstrap = build_bootstrap_client(&private_key); - let creds = client + let creds = bootstrap .create_or_derive_api_key(None) .await .expect("Failed to create API key"); - client.set_api_creds(creds); + let client = ClobClient::from_config(ClientConfig { + base_url: "https://clob-v2.polymarket.com".to_string(), + chain: 137, + private_key: Some(private_key), + api_credentials: Some(creds), + ..ClientConfig::default() + }) + .expect("failed to build authenticated client"); println!("Testing get_trades (requires HMAC auth)..."); let result = client.get_trades(None, None).await; @@ -121,13 +152,20 @@ async fn test_get_notifications() { let private_key = env::var("POLYMARKET_PRIVATE_KEY").expect("POLYMARKET_PRIVATE_KEY must be set in .env"); - let mut client = ClobClient::with_l1_headers("https://clob.polymarket.com", &private_key, 137); + let bootstrap = build_bootstrap_client(&private_key); - let creds = client + let creds = bootstrap .create_or_derive_api_key(None) .await .expect("Failed to create API key"); - client.set_api_creds(creds); + let client = ClobClient::from_config(ClientConfig { + base_url: "https://clob-v2.polymarket.com".to_string(), + chain: 137, + private_key: Some(private_key), + api_credentials: Some(creds), + ..ClientConfig::default() + }) + .expect("failed to build authenticated client"); println!("Testing get_notifications (requires HMAC auth)..."); let result = client.get_notifications().await; diff --git a/tests/ws_integration_tests.rs b/tests/ws_integration_tests.rs index aa3c405..33d8a50 100644 --- a/tests/ws_integration_tests.rs +++ b/tests/ws_integration_tests.rs @@ -8,11 +8,11 @@ #![cfg(feature = "stream")] use futures::StreamExt; -use polyfill_rs::{ClobClient, OrderBookManager, WebSocketStream, WsBookUpdateProcessor}; +use polyfill_rs::{ClientConfig, ClobClient, OrderBookManager, WebSocketStream, WsBookUpdateProcessor}; use std::env; use std::time::Duration; -const HOST: &str = "https://clob.polymarket.com"; +const HOST: &str = "https://clob-v2.polymarket.com"; const WS_MARKET_URL: &str = "wss://ws-subscriptions-clob.polymarket.com/ws/market"; const WS_USER_URL: &str = "wss://ws-subscriptions-clob.polymarket.com/ws/user"; const CHAIN_ID: u64 = 137; @@ -147,7 +147,13 @@ async fn test_real_ws_user_channel_connection_stable() { // during the window. This test primarily asserts we can authenticate + subscribe // and that the connection doesn't immediately drop. let private_key = load_private_key(); - let auth_client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID); + let auth_client = ClobClient::from_config(ClientConfig { + base_url: HOST.to_string(), + chain: CHAIN_ID, + private_key: Some(private_key), + ..ClientConfig::default() + }) + .expect("failed to build authenticated client"); let api_creds = auth_client .create_or_derive_api_key(None) .await