From 6e52dcbd0cd2c2df48da2f50b953e108cf5fb827 Mon Sep 17 00:00:00 2001 From: floor-licker Date: Tue, 3 Feb 2026 22:36:34 -0500 Subject: [PATCH] feat:implement rfq endpoints --- src/client.rs | 816 +++++++++++++++++++++++++++++++++++++- src/connection_manager.rs | 4 +- src/decode.rs | 13 + src/dns_cache.rs | 3 + src/http_config.rs | 6 + src/lib.rs | 14 + src/types.rs | 280 +++++++++++++ 7 files changed, 1128 insertions(+), 8 deletions(-) diff --git a/src/client.rs b/src/client.rs index d7480d7..938dfb8 100644 --- a/src/client.rs +++ b/src/client.rs @@ -76,13 +76,20 @@ impl ClobClient { // 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(|_| Client::new()); + .unwrap_or_else(|_| { + reqwest::ClientBuilder::new() + .no_proxy() + .build() + .expect("Failed to build reqwest client") + }); // Initialize DNS cache and pre-warm it let dns_cache = tokio::runtime::Handle::try_current().ok().and_then(|_| { @@ -134,7 +141,12 @@ impl ClobClient { /// Create a client optimized for co-located environments pub fn new_colocated(host: &str) -> Self { - let http_client = create_colocated_client().unwrap_or_else(|_| Client::new()); + let http_client = create_colocated_client().unwrap_or_else(|_| { + reqwest::ClientBuilder::new() + .no_proxy() + .build() + .expect("Failed to build reqwest client") + }); let connection_manager = Some(std::sync::Arc::new( crate::connection_manager::ConnectionManager::new( @@ -159,7 +171,12 @@ impl ClobClient { /// Create a client optimized for internet connections pub fn new_internet(host: &str) -> Self { - let http_client = create_internet_client().unwrap_or_else(|_| Client::new()); + let http_client = create_internet_client().unwrap_or_else(|_| { + reqwest::ClientBuilder::new() + .no_proxy() + .build() + .expect("Failed to build reqwest client") + }); let connection_manager = Some(std::sync::Arc::new( crate::connection_manager::ConnectionManager::new( @@ -190,7 +207,12 @@ impl ClobClient { let order_builder = crate::orders::OrderBuilder::new(signer.clone(), None, None); - let http_client = create_optimized_client().unwrap_or_else(|_| Client::new()); + 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 @@ -228,7 +250,12 @@ impl ClobClient { let order_builder = crate::orders::OrderBuilder::new(signer.clone(), None, None); - let http_client = create_optimized_client().unwrap_or_else(|_| Client::new()); + 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 @@ -610,6 +637,30 @@ impl ClobClient { Ok(tick_size) } + /// Get maker fee rate (in bps) for a token + pub async fn get_fee_rate_bps(&self, token_id: &str) -> Result { + let response = self + .http_client + .get(format!("{}/fee-rate", self.base_url)) + .query(&[("token_id", token_id)]) + .send() + .await + .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; + + if !response.status().is_success() { + return Err(PolyfillError::api( + response.status().as_u16(), + "Failed to get fee rate", + )); + } + + let fee_rate: crate::types::FeeRateResponse = response + .json() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None))?; + Ok(fee_rate.fee_rate_bps) + } + /// Create a new API key pub async fn create_api_key(&self, nonce: Option) -> Result { let signer = self @@ -1722,6 +1773,389 @@ impl ClobClient { .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None)) } + // ============================================================================ + // RFQ (Market Maker) endpoints + // ============================================================================ + + /// Create an RFQ request. + pub async fn create_rfq_request( + &self, + request: &crate::types::RfqCreateRequest, + ) -> Result { + let signer = self + .signer + .as_ref() + .ok_or_else(|| PolyfillError::auth("Signer not set"))?; + let api_creds = self + .api_creds + .as_ref() + .ok_or_else(|| PolyfillError::auth("API credentials not set"))?; + + let method = Method::POST; + let endpoint = "/rfq/request"; + let headers = + create_l2_headers(signer, api_creds, method.as_str(), endpoint, Some(request))?; + + let response = self + .create_request_with_headers(method, endpoint, headers.into_iter()) + .json(request) + .send() + .await + .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; + + if !response.status().is_success() { + return Err(PolyfillError::api( + response.status().as_u16(), + "Failed to create RFQ request", + )); + } + + response + .json() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None)) + } + + /// Cancel an RFQ request. + pub async fn cancel_rfq_request(&self, request_id: &str) -> Result<()> { + let signer = self + .signer + .as_ref() + .ok_or_else(|| PolyfillError::auth("Signer not set"))?; + let api_creds = self + .api_creds + .as_ref() + .ok_or_else(|| PolyfillError::auth("API credentials not set"))?; + + let method = Method::DELETE; + let endpoint = "/rfq/request"; + let body = crate::types::RfqCancelRequest { + request_id: request_id.to_string(), + }; + let headers = create_l2_headers(signer, api_creds, method.as_str(), endpoint, Some(&body))?; + + let response = self + .create_request_with_headers(method, endpoint, headers.into_iter()) + .json(&body) + .send() + .await + .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; + + if !response.status().is_success() { + return Err(PolyfillError::api( + response.status().as_u16(), + "Failed to cancel RFQ request", + )); + } + + Ok(()) + } + + /// Get RFQ requests (requester). + pub async fn get_rfq_requests( + &self, + params: Option<&crate::types::RfqRequestsParams>, + ) -> Result> { + let signer = self + .signer + .as_ref() + .ok_or_else(|| PolyfillError::auth("Signer not set"))?; + let api_creds = self + .api_creds + .as_ref() + .ok_or_else(|| PolyfillError::auth("API credentials not set"))?; + + let method = Method::GET; + let endpoint = "/rfq/data/requests"; + let headers = + create_l2_headers::(signer, api_creds, method.as_str(), endpoint, None)?; + + let query_params = params.cloned().unwrap_or_default().to_query_params(); + + let response = self + .create_request_with_headers(method, endpoint, headers.into_iter()) + .query(&query_params) + .send() + .await + .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; + + if !response.status().is_success() { + return Err(PolyfillError::api( + response.status().as_u16(), + "Failed to get RFQ requests", + )); + } + + response + .json() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None)) + } + + /// Create an RFQ quote. + pub async fn create_rfq_quote( + &self, + quote: &crate::types::RfqCreateQuote, + ) -> Result { + let signer = self + .signer + .as_ref() + .ok_or_else(|| PolyfillError::auth("Signer not set"))?; + let api_creds = self + .api_creds + .as_ref() + .ok_or_else(|| PolyfillError::auth("API credentials not set"))?; + + let method = Method::POST; + let endpoint = "/rfq/quote"; + let headers = create_l2_headers(signer, api_creds, method.as_str(), endpoint, Some(quote))?; + + let response = self + .create_request_with_headers(method, endpoint, headers.into_iter()) + .json(quote) + .send() + .await + .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; + + if !response.status().is_success() { + return Err(PolyfillError::api( + response.status().as_u16(), + "Failed to create RFQ quote", + )); + } + + response + .json() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None)) + } + + /// Cancel an RFQ quote. + pub async fn cancel_rfq_quote(&self, quote_id: &str) -> Result<()> { + let signer = self + .signer + .as_ref() + .ok_or_else(|| PolyfillError::auth("Signer not set"))?; + let api_creds = self + .api_creds + .as_ref() + .ok_or_else(|| PolyfillError::auth("API credentials not set"))?; + + let method = Method::DELETE; + let endpoint = "/rfq/quote"; + let body = crate::types::RfqCancelQuote { + quote_id: quote_id.to_string(), + }; + let headers = create_l2_headers(signer, api_creds, method.as_str(), endpoint, Some(&body))?; + + let response = self + .create_request_with_headers(method, endpoint, headers.into_iter()) + .json(&body) + .send() + .await + .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; + + if !response.status().is_success() { + return Err(PolyfillError::api( + response.status().as_u16(), + "Failed to cancel RFQ quote", + )); + } + + Ok(()) + } + + /// Get quotes for the requester. + pub async fn get_rfq_requester_quotes( + &self, + params: Option<&crate::types::RfqQuotesParams>, + ) -> Result> { + let signer = self + .signer + .as_ref() + .ok_or_else(|| PolyfillError::auth("Signer not set"))?; + let api_creds = self + .api_creds + .as_ref() + .ok_or_else(|| PolyfillError::auth("API credentials not set"))?; + + let method = Method::GET; + let endpoint = "/rfq/data/requester/quotes"; + let headers = + create_l2_headers::(signer, api_creds, method.as_str(), endpoint, None)?; + + let query_params = params.cloned().unwrap_or_default().to_query_params(); + + let response = self + .create_request_with_headers(method, endpoint, headers.into_iter()) + .query(&query_params) + .send() + .await + .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; + + if !response.status().is_success() { + return Err(PolyfillError::api( + response.status().as_u16(), + "Failed to get RFQ requester quotes", + )); + } + + response + .json() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None)) + } + + /// Get quotes for the quoter. + pub async fn get_rfq_quoter_quotes( + &self, + params: Option<&crate::types::RfqQuotesParams>, + ) -> Result> { + let signer = self + .signer + .as_ref() + .ok_or_else(|| PolyfillError::auth("Signer not set"))?; + let api_creds = self + .api_creds + .as_ref() + .ok_or_else(|| PolyfillError::auth("API credentials not set"))?; + + let method = Method::GET; + let endpoint = "/rfq/data/quoter/quotes"; + let headers = + create_l2_headers::(signer, api_creds, method.as_str(), endpoint, None)?; + + let query_params = params.cloned().unwrap_or_default().to_query_params(); + + let response = self + .create_request_with_headers(method, endpoint, headers.into_iter()) + .query(&query_params) + .send() + .await + .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; + + if !response.status().is_success() { + return Err(PolyfillError::api( + response.status().as_u16(), + "Failed to get RFQ quoter quotes", + )); + } + + response + .json() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None)) + } + + /// Get best quote for a request. + pub async fn get_rfq_best_quote(&self, request_id: &str) -> Result { + let signer = self + .signer + .as_ref() + .ok_or_else(|| PolyfillError::auth("Signer not set"))?; + let api_creds = self + .api_creds + .as_ref() + .ok_or_else(|| PolyfillError::auth("API credentials not set"))?; + + let method = Method::GET; + let endpoint = "/rfq/data/best-quote"; + let headers = + create_l2_headers::(signer, api_creds, method.as_str(), endpoint, None)?; + + let response = self + .create_request_with_headers(method, endpoint, headers.into_iter()) + .query(&[("requestId", request_id)]) + .send() + .await + .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; + + if !response.status().is_success() { + return Err(PolyfillError::api( + response.status().as_u16(), + "Failed to get RFQ best quote", + )); + } + + response + .json() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None)) + } + + /// Accept the best quote and post the resulting order. + pub async fn accept_rfq_quote( + &self, + body: &crate::types::RfqOrderExecutionRequest, + ) -> Result<()> { + let signer = self + .signer + .as_ref() + .ok_or_else(|| PolyfillError::auth("Signer not set"))?; + let api_creds = self + .api_creds + .as_ref() + .ok_or_else(|| PolyfillError::auth("API credentials not set"))?; + + let method = Method::POST; + let endpoint = "/rfq/request/accept"; + let headers = create_l2_headers(signer, api_creds, method.as_str(), endpoint, Some(body))?; + + let response = self + .create_request_with_headers(method, endpoint, headers.into_iter()) + .json(body) + .send() + .await + .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; + + if !response.status().is_success() { + return Err(PolyfillError::api( + response.status().as_u16(), + "Failed to accept RFQ quote", + )); + } + + Ok(()) + } + + /// Approve the accepted quote's order (Quoter). + pub async fn approve_rfq_order( + &self, + body: &crate::types::RfqOrderExecutionRequest, + ) -> Result { + let signer = self + .signer + .as_ref() + .ok_or_else(|| PolyfillError::auth("Signer not set"))?; + let api_creds = self + .api_creds + .as_ref() + .ok_or_else(|| PolyfillError::auth("API credentials not set"))?; + + let method = Method::POST; + let endpoint = "/rfq/quote/approve"; + let headers = create_l2_headers(signer, api_creds, method.as_str(), endpoint, Some(body))?; + + let response = self + .create_request_with_headers(method, endpoint, headers.into_iter()) + .json(body) + .send() + .await + .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; + + if !response.status().is_success() { + return Err(PolyfillError::api( + response.status().as_u16(), + "Failed to approve RFQ order", + )); + } + + response + .json() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None)) + } + /// Get sampling markets with pagination pub async fn get_sampling_markets( &self, @@ -1860,10 +2294,14 @@ pub type PolyfillClient = ClobClient; #[cfg(test)] mod tests { use super::{ClobClient, OrderArgs as ClientOrderArgs}; - use crate::types::{PricesHistoryInterval, Side}; + use crate::types::{ + PricesHistoryInterval, RfqCreateQuote, RfqCreateRequest, RfqOrderExecutionRequest, + RfqQuotesParams, RfqRequestsParams, Side, + }; use crate::{ApiCredentials, PolyfillError}; use mockito::{Matcher, Server}; use rust_decimal::Decimal; + use serde_json::json; use std::str::FromStr; use tokio; @@ -1879,6 +2317,22 @@ mod tests { ) } + fn create_test_client_with_l2_auth(base_url: &str) -> ClobClient { + let api_creds = ApiCredentials { + api_key: "test_key".to_string(), + // URL-safe base64 so HMAC header generation succeeds. + secret: "dGVzdF9zZWNyZXRfa2V5XzEyMzQ1".to_string(), + passphrase: "test_passphrase".to_string(), + }; + + ClobClient::with_l2_headers( + base_url, + "0x1234567890123456789012345678901234567890123456789012345678901234", + 137, + api_creds, + ) + } + #[tokio::test(flavor = "multi_thread")] async fn test_client_creation() { let client = create_test_client("https://test.example.com"); @@ -2572,4 +3026,354 @@ mod tests { assert_eq!(default_args.size, Decimal::ZERO); assert_eq!(default_args.side, Side::BUY); } + + #[tokio::test(flavor = "multi_thread")] + async fn test_get_fee_rate_bps_success() { + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/fee-rate") + .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}"#) + .create_async() + .await; + + let client = create_test_client(&server.url()); + let rate = client.get_fee_rate_bps("123").await.unwrap(); + + mock.assert_async().await; + assert_eq!(rate, 1000); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_rfq_endpoints_happy_path() { + let mut server = Server::new_async().await; + + // create_rfq_request + let create_request = RfqCreateRequest { + asset_in: "some_asset_in".to_string(), + asset_out: "some_asset_out".to_string(), + amount_in: "100".to_string(), + amount_out: "200".to_string(), + user_type: 0, + }; + let create_request_mock = server + .mock("POST", "/rfq/request") + .match_body(Matcher::JsonString( + json!({ + "assetIn": "some_asset_in", + "assetOut": "some_asset_out", + "amountIn": "100", + "amountOut": "200", + "userType": 0 + }) + .to_string(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"requestId":"req123","expiry":1744936318}"#) + .create_async() + .await; + + // cancel_rfq_request + let cancel_request_mock = server + .mock("DELETE", "/rfq/request") + .match_body(Matcher::JsonString(r#"{"requestId":"req123"}"#.to_string())) + .with_status(200) + .with_body("OK") + .create_async() + .await; + + // get_rfq_requests + let rfq_requests_mock = server + .mock("GET", "/rfq/data/requests") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("offset".into(), "MA==".into()), + Matcher::UrlEncoded("limit".into(), "10".into()), + Matcher::UrlEncoded("state".into(), "active".into()), + Matcher::UrlEncoded("requestIds[]".into(), "req123".into()), + Matcher::UrlEncoded("markets[]".into(), "some_market".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "data": [{ + "requestId": "req123", + "userAddress": "0xabc", + "proxyAddress": "0xdef", + "condition": "some_condition_id", + "token": "some_token_id", + "complement": "some_complement", + "side": "BUY", + "sizeIn": 100, + "sizeOut": 200, + "price": 0.5, + "state": "active", + "expiry": 1744936318 + }], + "next_cursor": "MA==", + "limit": 10, + "count": 1 + }"#, + ) + .create_async() + .await; + + // create_rfq_quote + let create_quote = RfqCreateQuote { + request_id: "req123".to_string(), + asset_in: "some_asset_in".to_string(), + asset_out: "some_asset_out".to_string(), + amount_in: "100".to_string(), + amount_out: "200".to_string(), + user_type: 0, + }; + let create_quote_mock = server + .mock("POST", "/rfq/quote") + .match_body(Matcher::JsonString( + json!({ + "requestId": "req123", + "assetIn": "some_asset_in", + "assetOut": "some_asset_out", + "amountIn": "100", + "amountOut": "200", + "userType": 0 + }) + .to_string(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"quoteId":"q123"}"#) + .create_async() + .await; + + // cancel_rfq_quote + let cancel_quote_mock = server + .mock("DELETE", "/rfq/quote") + .match_body(Matcher::JsonString(r#"{"quoteId":"q123"}"#.to_string())) + .with_status(200) + .with_body("OK") + .create_async() + .await; + + // get_rfq_requester_quotes + let requester_quotes_mock = server + .mock("GET", "/rfq/data/requester/quotes") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("offset".into(), "MA==".into()), + Matcher::UrlEncoded("limit".into(), "10".into()), + Matcher::UrlEncoded("state".into(), "active".into()), + Matcher::UrlEncoded("quoteIds[]".into(), "q123".into()), + Matcher::UrlEncoded("requestIds[]".into(), "req123".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "data": [{ + "quoteId": "q123", + "requestId": "req123", + "userAddress": "0xabc", + "proxyAddress": "0xdef", + "condition": "some_condition_id", + "token": "some_token_id", + "complement": "some_complement", + "side": "BUY", + "sizeIn": 100, + "sizeOut": 200, + "price": 0.5, + "matchType": "matched", + "state": "active" + }], + "next_cursor": "MA==", + "limit": 10, + "count": 1 + }"#, + ) + .create_async() + .await; + + // get_rfq_quoter_quotes + let quoter_quotes_mock = server + .mock("GET", "/rfq/data/quoter/quotes") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "data": [], + "next_cursor": "MA==", + "limit": 10, + "count": 0 + }"#, + ) + .create_async() + .await; + + // get_rfq_best_quote + let best_quote_mock = server + .mock("GET", "/rfq/data/best-quote") + .match_query(Matcher::UrlEncoded("requestId".into(), "req123".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "quoteId": "q123", + "requestId": "req123", + "userAddress": "0xabc", + "proxyAddress": "0xdef", + "condition": "some_condition_id", + "token": "some_token_id", + "complement": "some_complement", + "side": "BUY", + "sizeIn": 100, + "sizeOut": 200, + "price": 0.5, + "matchType": "matched", + "state": "active" + }"#, + ) + .create_async() + .await; + + // accept_rfq_quote + let exec = RfqOrderExecutionRequest { + request_id: "req123".to_string(), + quote_id: "q123".to_string(), + maker: "0xmaker".to_string(), + signer: "0xsigner".to_string(), + taker: "0xtaker".to_string(), + expiration: 1_740_000_000, + nonce: "123".to_string(), + fee_rate_bps: "1000".to_string(), + side: "BUY".to_string(), + token_id: "123".to_string(), + maker_amount: "100".to_string(), + taker_amount: "200".to_string(), + signature_type: 2, + signature: "0xsig".to_string(), + salt: 42, + owner: "owner".to_string(), + }; + + let accept_mock = server + .mock("POST", "/rfq/request/accept") + .match_body(Matcher::JsonString( + json!({ + "requestId": "req123", + "quoteId": "q123", + "maker": "0xmaker", + "signer": "0xsigner", + "taker": "0xtaker", + "expiration": 1740000000, + "nonce": "123", + "feeRateBps": "1000", + "side": "BUY", + "tokenId": "123", + "makerAmount": "100", + "takerAmount": "200", + "signatureType": 2, + "signature": "0xsig", + "salt": 42, + "owner": "owner" + }) + .to_string(), + )) + .with_status(200) + .with_body("OK") + .create_async() + .await; + + // approve_rfq_order + let approve_mock = server + .mock("POST", "/rfq/quote/approve") + .match_body(Matcher::JsonString( + json!({ + "requestId": "req123", + "quoteId": "q123", + "maker": "0xmaker", + "signer": "0xsigner", + "taker": "0xtaker", + "expiration": 1740000000, + "nonce": "123", + "feeRateBps": "1000", + "side": "BUY", + "tokenId": "123", + "makerAmount": "100", + "takerAmount": "200", + "signatureType": 2, + "signature": "0xsig", + "salt": 42, + "owner": "owner" + }) + .to_string(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"tradeIds":["t1","t2"]}"#) + .create_async() + .await; + + let client = create_test_client_with_l2_auth(&server.url()); + + let created = client.create_rfq_request(&create_request).await.unwrap(); + assert_eq!(created.request_id, "req123"); + assert_eq!(created.expiry, 1_744_936_318); + create_request_mock.assert_async().await; + + client.cancel_rfq_request("req123").await.unwrap(); + cancel_request_mock.assert_async().await; + + let params = RfqRequestsParams { + offset: Some("MA==".to_string()), + limit: Some(10), + state: Some("active".to_string()), + request_ids: vec!["req123".to_string()], + markets: vec!["some_market".to_string()], + ..Default::default() + }; + let requests = client.get_rfq_requests(Some(¶ms)).await.unwrap(); + assert_eq!(requests.data.len(), 1); + assert_eq!(requests.data[0].request_id, "req123"); + rfq_requests_mock.assert_async().await; + + let quote = client.create_rfq_quote(&create_quote).await.unwrap(); + assert_eq!(quote.quote_id, "q123"); + create_quote_mock.assert_async().await; + + client.cancel_rfq_quote("q123").await.unwrap(); + cancel_quote_mock.assert_async().await; + + let quote_params = RfqQuotesParams { + offset: Some("MA==".to_string()), + limit: Some(10), + state: Some("active".to_string()), + quote_ids: vec!["q123".to_string()], + request_ids: vec!["req123".to_string()], + ..Default::default() + }; + + let requester_quotes = client + .get_rfq_requester_quotes(Some("e_params)) + .await + .unwrap(); + assert_eq!(requester_quotes.data.len(), 1); + requester_quotes_mock.assert_async().await; + + let quoter_quotes = client.get_rfq_quoter_quotes(None).await.unwrap(); + assert_eq!(quoter_quotes.data.len(), 0); + quoter_quotes_mock.assert_async().await; + + let best = client.get_rfq_best_quote("req123").await.unwrap(); + assert_eq!(best.quote_id, "q123"); + best_quote_mock.assert_async().await; + + client.accept_rfq_quote(&exec).await.unwrap(); + accept_mock.assert_async().await; + + let approved = client.approve_rfq_order(&exec).await.unwrap(); + assert_eq!(approved.trade_ids, vec!["t1".to_string(), "t2".to_string()]); + approve_mock.assert_async().await; + } } diff --git a/src/connection_manager.rs b/src/connection_manager.rs index fba8b79..301e087 100644 --- a/src/connection_manager.rs +++ b/src/connection_manager.rs @@ -100,14 +100,14 @@ mod tests { #[tokio::test] async fn test_connection_manager_creation() { - let client = Client::new(); + let client = reqwest::ClientBuilder::new().no_proxy().build().unwrap(); let manager = ConnectionManager::new(client, "https://clob.polymarket.com".to_string()); assert!(!manager.is_running()); } #[tokio::test] async fn test_keepalive_start_stop() { - let client = Client::new(); + let client = reqwest::ClientBuilder::new().no_proxy().build().unwrap(); let manager = ConnectionManager::new(client, "https://clob.polymarket.com".to_string()); manager.start_keepalive(Duration::from_secs(30)).await; diff --git a/src/decode.rs b/src/decode.rs index 76c55e1..dab75c7 100644 --- a/src/decode.rs +++ b/src/decode.rs @@ -173,6 +173,19 @@ pub mod deserializers { _ => Ok(None), } } + + /// Deserialize a Decimal from string/number. + /// + /// - `""` => error + /// - invalid values => error + pub fn decimal_from_string<'de, D>(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + optional_decimal_from_string(deserializer)?.ok_or_else(|| { + serde::de::Error::custom("Expected decimal as string/number, got null/empty string") + }) + } } /// Raw API response types for efficient parsing diff --git a/src/dns_cache.rs b/src/dns_cache.rs index ee344de..657ebcb 100644 --- a/src/dns_cache.rs +++ b/src/dns_cache.rs @@ -102,6 +102,7 @@ mod tests { use super::*; #[tokio::test] + #[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(); @@ -109,6 +110,7 @@ mod tests { } #[tokio::test] + #[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(); @@ -116,6 +118,7 @@ mod tests { } #[tokio::test] + #[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(); diff --git a/src/http_config.rs b/src/http_config.rs index e34d984..620c1d8 100644 --- a/src/http_config.rs +++ b/src/http_config.rs @@ -26,6 +26,8 @@ pub async fn prewarm_connections(client: &Client, base_url: &str) -> Result<(), /// Benchmarked configuration: 309.3ms vs 349ms baseline (11.4% faster) pub fn create_optimized_client() -> Result { ClientBuilder::new() + // Avoid reading OS proxy settings (can be slow and/or unavailable in some sandboxed envs) + .no_proxy() // Connection pooling optimizations - aggressive reuse .pool_max_idle_per_host(10) // Keep connections alive .pool_idle_timeout(Duration::from_secs(90)) // Longer reuse window @@ -49,6 +51,8 @@ pub fn create_optimized_client() -> Result { /// (even more aggressive settings for when you're close to the exchange) pub fn create_colocated_client() -> Result { ClientBuilder::new() + // Avoid reading OS proxy settings (can be slow and/or unavailable in some sandboxed envs) + .no_proxy() // More aggressive connection pooling .pool_max_idle_per_host(20) // More connections .pool_idle_timeout(Duration::from_secs(60)) // Longer reuse @@ -78,6 +82,8 @@ pub fn create_colocated_client() -> Result { /// (more conservative settings for internet connections) pub fn create_internet_client() -> Result { ClientBuilder::new() + // Avoid reading OS proxy settings (can be slow and/or unavailable in some sandboxed envs) + .no_proxy() // Conservative connection pooling .pool_max_idle_per_host(5) .pool_idle_timeout(Duration::from_secs(90)) diff --git a/src/lib.rs b/src/lib.rs index f10b43a..d7c5b08 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -99,6 +99,7 @@ pub use crate::types::{ BookParams, ClientConfig, ClientResult, + FeeRateResponse, FillEvent, Market, MarketSnapshot, @@ -120,6 +121,19 @@ pub use crate::types::{ PricesHistoryInterval, PricesHistoryResponse, Rewards, + RfqApproveOrderResponse, + RfqCancelQuote, + RfqCancelRequest, + RfqCreateQuote, + RfqCreateQuoteResponse, + RfqCreateRequest, + RfqCreateRequestResponse, + RfqListResponse, + RfqOrderExecutionRequest, + RfqQuoteData, + RfqQuotesParams, + RfqRequestData, + RfqRequestsParams, Side, SimplifiedMarket, SimplifiedMarketsResponse, diff --git a/src/types.rs b/src/types.rs index d983138..eaecc24 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1367,6 +1367,286 @@ pub struct Rewards { pub reward_epoch: Option, } +// ============================================================================ +// CLOB API: Fee Rate + RFQ (Market Maker) Types +// ============================================================================ + +/// Fee rate in basis points for a given token. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeeRateResponse { + pub fee_rate_bps: u32, +} + +/// Create RFQ request (Requester). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RfqCreateRequest { + pub asset_in: String, + pub asset_out: String, + pub amount_in: String, + pub amount_out: String, + pub user_type: u8, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RfqCreateRequestResponse { + pub request_id: String, + pub expiry: u64, +} + +/// Cancel RFQ request (Requester). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RfqCancelRequest { + pub request_id: String, +} + +/// RFQ request list query parameters. +#[derive(Debug, Clone, Default)] +pub struct RfqRequestsParams { + pub offset: Option, + pub limit: Option, + pub state: Option, + pub request_ids: Vec, + pub markets: Vec, + pub size_min: Option, + pub size_max: Option, + pub size_usdc_min: Option, + pub size_usdc_max: Option, + pub price_min: Option, + pub price_max: Option, + pub sort_by: Option, + pub sort_dir: Option, +} + +impl RfqRequestsParams { + pub fn to_query_params(&self) -> Vec<(String, String)> { + let mut params = Vec::new(); + + if let Some(x) = &self.offset { + params.push(("offset".to_string(), x.clone())); + } + if let Some(x) = self.limit { + params.push(("limit".to_string(), x.to_string())); + } + if let Some(x) = &self.state { + params.push(("state".to_string(), x.clone())); + } + for x in &self.request_ids { + params.push(("requestIds[]".to_string(), x.clone())); + } + for x in &self.markets { + params.push(("markets[]".to_string(), x.clone())); + } + + if let Some(x) = self.size_min { + params.push(("sizeMin".to_string(), x.to_string())); + } + if let Some(x) = self.size_max { + params.push(("sizeMax".to_string(), x.to_string())); + } + if let Some(x) = self.size_usdc_min { + params.push(("sizeUsdcMin".to_string(), x.to_string())); + } + if let Some(x) = self.size_usdc_max { + params.push(("sizeUsdcMax".to_string(), x.to_string())); + } + if let Some(x) = self.price_min { + params.push(("priceMin".to_string(), x.to_string())); + } + if let Some(x) = self.price_max { + params.push(("priceMax".to_string(), x.to_string())); + } + + if let Some(x) = &self.sort_by { + params.push(("sortBy".to_string(), x.clone())); + } + if let Some(x) = &self.sort_dir { + params.push(("sortDir".to_string(), x.clone())); + } + + params + } +} + +/// RFQ request data. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RfqRequestData { + pub request_id: String, + pub user_address: String, + pub proxy_address: String, + pub condition: String, + pub token: String, + pub complement: String, + pub side: Side, + #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + pub size_in: Decimal, + #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + pub size_out: Decimal, + #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + pub price: Decimal, + pub state: String, + pub expiry: u64, +} + +/// Create RFQ quote (Quoter). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RfqCreateQuote { + pub request_id: String, + pub asset_in: String, + pub asset_out: String, + pub amount_in: String, + pub amount_out: String, + pub user_type: u8, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RfqCreateQuoteResponse { + pub quote_id: String, +} + +/// Cancel RFQ quote (Quoter). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RfqCancelQuote { + pub quote_id: String, +} + +/// RFQ quote list query parameters. +#[derive(Debug, Clone, Default)] +pub struct RfqQuotesParams { + pub offset: Option, + pub limit: Option, + pub state: Option, + pub quote_ids: Vec, + pub request_ids: Vec, + pub markets: Vec, + pub size_min: Option, + pub size_max: Option, + pub size_usdc_min: Option, + pub size_usdc_max: Option, + pub price_min: Option, + pub price_max: Option, + pub sort_by: Option, + pub sort_dir: Option, +} + +impl RfqQuotesParams { + pub fn to_query_params(&self) -> Vec<(String, String)> { + let mut params = Vec::new(); + + if let Some(x) = &self.offset { + params.push(("offset".to_string(), x.clone())); + } + if let Some(x) = self.limit { + params.push(("limit".to_string(), x.to_string())); + } + if let Some(x) = &self.state { + params.push(("state".to_string(), x.clone())); + } + for x in &self.quote_ids { + params.push(("quoteIds[]".to_string(), x.clone())); + } + for x in &self.request_ids { + params.push(("requestIds[]".to_string(), x.clone())); + } + for x in &self.markets { + params.push(("markets[]".to_string(), x.clone())); + } + + if let Some(x) = self.size_min { + params.push(("sizeMin".to_string(), x.to_string())); + } + if let Some(x) = self.size_max { + params.push(("sizeMax".to_string(), x.to_string())); + } + if let Some(x) = self.size_usdc_min { + params.push(("sizeUsdcMin".to_string(), x.to_string())); + } + if let Some(x) = self.size_usdc_max { + params.push(("sizeUsdcMax".to_string(), x.to_string())); + } + if let Some(x) = self.price_min { + params.push(("priceMin".to_string(), x.to_string())); + } + if let Some(x) = self.price_max { + params.push(("priceMax".to_string(), x.to_string())); + } + + if let Some(x) = &self.sort_by { + params.push(("sortBy".to_string(), x.clone())); + } + if let Some(x) = &self.sort_dir { + params.push(("sortDir".to_string(), x.clone())); + } + + params + } +} + +/// RFQ quote data. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RfqQuoteData { + pub quote_id: String, + pub request_id: String, + pub user_address: String, + pub proxy_address: String, + pub condition: String, + pub token: String, + pub complement: String, + pub side: Side, + #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + pub size_in: Decimal, + #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + pub size_out: Decimal, + #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + pub price: Decimal, + pub match_type: String, + pub state: String, +} + +/// Generic RFQ list response wrapper. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RfqListResponse { + pub data: Vec, + pub next_cursor: Option, + pub limit: u32, + pub count: u32, +} + +/// RFQ order execution request (used for both accept + approve). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RfqOrderExecutionRequest { + pub request_id: String, + pub quote_id: String, + pub maker: String, + pub signer: String, + pub taker: String, + pub expiration: u64, + pub nonce: String, + pub fee_rate_bps: String, + pub side: String, + pub token_id: String, + pub maker_amount: String, + pub taker_amount: String, + pub signature_type: u8, + pub signature: String, + pub salt: u64, + pub owner: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RfqApproveOrderResponse { + pub trade_ids: Vec, +} + // For compatibility with reference implementation pub type ClientResult = anyhow::Result;