feat:implement rfq endpoints

This commit is contained in:
floor-licker
2026-02-03 22:36:34 -05:00
parent 29307e4c90
commit 6e52dcbd0c
7 changed files with 1128 additions and 8 deletions
+810 -6
View File
@@ -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<u32> {
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<U256>) -> Result<ApiCreds> {
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<crate::types::RfqCreateRequestResponse> {
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<crate::types::RfqListResponse<crate::types::RfqRequestData>> {
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::<Value>(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<crate::types::RfqCreateQuoteResponse> {
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<crate::types::RfqListResponse<crate::types::RfqQuoteData>> {
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::<Value>(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<crate::types::RfqListResponse<crate::types::RfqQuoteData>> {
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::<Value>(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<crate::types::RfqQuoteData> {
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::<Value>(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<crate::types::RfqApproveOrderResponse> {
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(&params)).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(&quote_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;
}
}
+2 -2
View File
@@ -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;
+13
View File
@@ -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<Decimal, D::Error>
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
+3
View File
@@ -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();
+6
View File
@@ -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<Client, reqwest::Error> {
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<Client, reqwest::Error> {
/// (even more aggressive settings for when you're close to the exchange)
pub fn create_colocated_client() -> Result<Client, reqwest::Error> {
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<Client, reqwest::Error> {
/// (more conservative settings for internet connections)
pub fn create_internet_client() -> Result<Client, reqwest::Error> {
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))
+14
View File
@@ -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,
+280
View File
@@ -1367,6 +1367,286 @@ pub struct Rewards {
pub reward_epoch: Option<Decimal>,
}
// ============================================================================
// 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<String>,
pub limit: Option<u32>,
pub state: Option<String>,
pub request_ids: Vec<String>,
pub markets: Vec<String>,
pub size_min: Option<Decimal>,
pub size_max: Option<Decimal>,
pub size_usdc_min: Option<Decimal>,
pub size_usdc_max: Option<Decimal>,
pub price_min: Option<Decimal>,
pub price_max: Option<Decimal>,
pub sort_by: Option<String>,
pub sort_dir: Option<String>,
}
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<String>,
pub limit: Option<u32>,
pub state: Option<String>,
pub quote_ids: Vec<String>,
pub request_ids: Vec<String>,
pub markets: Vec<String>,
pub size_min: Option<Decimal>,
pub size_max: Option<Decimal>,
pub size_usdc_min: Option<Decimal>,
pub size_usdc_max: Option<Decimal>,
pub price_min: Option<Decimal>,
pub price_max: Option<Decimal>,
pub sort_by: Option<String>,
pub sort_dir: Option<String>,
}
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<T> {
pub data: Vec<T>,
pub next_cursor: Option<String>,
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<String>,
}
// For compatibility with reference implementation
pub type ClientResult<T> = anyhow::Result<T>;