From 30d4ea3937613e1cb0e3d85f7fa7a28a75a9776e Mon Sep 17 00:00:00 2001 From: Andrey Kuznetsov Date: Thu, 12 Feb 2026 23:19:14 +0000 Subject: [PATCH 1/6] feat(clob): add batch order posting support --- src/client.rs | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/client.rs b/src/client.rs index 938dfb8..101dcc9 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1032,12 +1032,68 @@ impl ClobClient { Ok(response.json::().await?) } + /// Post multiple orders to the exchange in a single request + pub async fn post_orders( + &self, + orders: Vec, + order_type: OrderType, + ) -> Result { + if orders.is_empty() { + return Err(PolyfillError::validation("orders cannot be empty")); + } + + 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 body: Vec = orders + .into_iter() + .map(|order| PostOrder::new(order, api_creds.api_key.clone(), order_type)) + .collect(); + + let headers = create_l2_headers(signer, api_creds, "POST", "/orders", Some(&body))?; + let req = self.create_request_with_headers(Method::POST, "/orders", headers.into_iter()); + + let response = req.json(&body).send().await?; + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + let message = if body.is_empty() { + "Failed to post orders".to_string() + } else { + format!("Failed to post orders: {}", body) + }; + return Err(PolyfillError::api(status, message)); + } + + Ok(response.json::().await?) + } + /// 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 } + /// Create and post multiple orders in one call + pub async fn create_and_post_orders(&self, order_args: &[OrderArgs]) -> Result { + if order_args.is_empty() { + return Err(PolyfillError::validation("order_args cannot be empty")); + } + + let mut orders = Vec::with_capacity(order_args.len()); + for args in order_args { + orders.push(self.create_order(args, None, None, None).await?); + } + + self.post_orders(orders, OrderType::GTC).await + } + /// Cancel an order pub async fn cancel(&self, order_id: &str) -> Result { let signer = self @@ -2874,6 +2930,53 @@ mod tests { assert_eq!(timestamp, 1234567890); } + #[tokio::test(flavor = "multi_thread")] + async fn test_post_orders_batch_success() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/orders") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"success":true,"orderIDs":["a","b"]}"#) + .create_async() + .await; + + let client = create_test_client_with_l2_auth(&server.url()); + let signed_order = crate::types::SignedOrderRequest { + salt: 1, + maker: "0x0000000000000000000000000000000000000000".to_string(), + signer: "0x0000000000000000000000000000000000000000".to_string(), + taker: "0x0000000000000000000000000000000000000000".to_string(), + token_id: "123".to_string(), + maker_amount: "100".to_string(), + taker_amount: "50".to_string(), + expiration: "0".to_string(), + nonce: "0".to_string(), + fee_rate_bps: "0".to_string(), + side: "BUY".to_string(), + signature_type: 0, + signature: "0xdeadbeef".to_string(), + }; + + let result = client + .post_orders( + vec![signed_order.clone(), signed_order], + crate::types::OrderType::GTC, + ) + .await; + + mock.assert_async().await; + assert!(result.is_ok()); + assert_eq!(result.unwrap()["success"], true); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_post_orders_batch_empty_validation() { + let client = create_test_client_with_l2_auth("https://test.example.com"); + let result = client.post_orders(Vec::new(), crate::types::OrderType::GTC).await; + assert!(matches!(result, Err(PolyfillError::Validation { .. }))); + } + #[tokio::test(flavor = "multi_thread")] async fn test_create_or_derive_api_key() { let mut server = Server::new_async().await; From d9627d8f6c72f433c63dd106690139d12b53811c Mon Sep 17 00:00:00 2001 From: Andrey Kuznetsov Date: Thu, 12 Feb 2026 23:19:48 +0000 Subject: [PATCH 2/6] feat(clob): enforce 15-order limit for batch posting --- src/client.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/client.rs b/src/client.rs index 101dcc9..3c23447 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1041,6 +1041,11 @@ impl ClobClient { if orders.is_empty() { return Err(PolyfillError::validation("orders cannot be empty")); } + if orders.len() > 15 { + return Err(PolyfillError::validation( + "orders cannot exceed 15 items per batch", + )); + } let signer = self .signer @@ -2977,6 +2982,31 @@ mod tests { assert!(matches!(result, Err(PolyfillError::Validation { .. }))); } + #[tokio::test(flavor = "multi_thread")] + async fn test_post_orders_batch_too_many_validation() { + let client = create_test_client_with_l2_auth("https://test.example.com"); + let orders = (0..16) + .map(|_| crate::types::SignedOrderRequest { + salt: 1, + maker: "0x0000000000000000000000000000000000000000".to_string(), + signer: "0x0000000000000000000000000000000000000000".to_string(), + taker: "0x0000000000000000000000000000000000000000".to_string(), + token_id: "123".to_string(), + maker_amount: "100".to_string(), + taker_amount: "50".to_string(), + expiration: "0".to_string(), + nonce: "0".to_string(), + fee_rate_bps: "0".to_string(), + side: "BUY".to_string(), + signature_type: 0, + signature: "0xdeadbeef".to_string(), + }) + .collect::>(); + + let result = client.post_orders(orders, crate::types::OrderType::GTC).await; + assert!(matches!(result, Err(PolyfillError::Validation { .. }))); + } + #[tokio::test(flavor = "multi_thread")] async fn test_create_or_derive_api_key() { let mut server = Server::new_async().await; From 6190a24e0c193f3c5fe22cf32d232ff07e2c272c Mon Sep 17 00:00:00 2001 From: Andrey Kuznetsov Date: Thu, 12 Feb 2026 23:23:00 +0000 Subject: [PATCH 3/6] feat(clob): support all order types for order posting --- src/client.rs | 126 +++++++++++++++++++++++++++++++++++--------------- src/types.rs | 18 ++++++++ 2 files changed, 108 insertions(+), 36 deletions(-) diff --git a/src/client.rs b/src/client.rs index 3c23447..516e5f1 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1079,14 +1079,28 @@ impl ClobClient { Ok(response.json::().await?) } - /// Create and post an order in one call - pub async fn create_and_post_order(&self, order_args: &OrderArgs) -> Result { + /// Create and post an order in one call with an explicit order type + pub async fn create_and_post_order_with_type( + &self, + order_args: &OrderArgs, + order_type: OrderType, + ) -> Result { let order = self.create_order(order_args, None, None, None).await?; - self.post_order(order, OrderType::GTC).await + self.post_order(order, order_type).await } - /// Create and post multiple orders in one call - pub async fn create_and_post_orders(&self, order_args: &[OrderArgs]) -> Result { + /// Create and post an order in one call (defaults to GTC) + pub async fn create_and_post_order(&self, order_args: &OrderArgs) -> Result { + self.create_and_post_order_with_type(order_args, OrderType::GTC) + .await + } + + /// Create and post multiple orders in one call with an explicit order type + pub async fn create_and_post_orders_with_type( + &self, + order_args: &[OrderArgs], + order_type: OrderType, + ) -> Result { if order_args.is_empty() { return Err(PolyfillError::validation("order_args cannot be empty")); } @@ -1096,7 +1110,13 @@ impl ClobClient { orders.push(self.create_order(args, None, None, None).await?); } - self.post_orders(orders, OrderType::GTC).await + self.post_orders(orders, order_type).await + } + + /// Create and post multiple orders in one call (defaults to GTC) + pub async fn create_and_post_orders(&self, order_args: &[OrderArgs]) -> Result { + self.create_and_post_orders_with_type(order_args, OrderType::GTC) + .await } /// Cancel an order @@ -2394,6 +2414,24 @@ mod tests { ) } + fn sample_signed_order() -> crate::types::SignedOrderRequest { + crate::types::SignedOrderRequest { + salt: 1, + maker: "0x0000000000000000000000000000000000000000".to_string(), + signer: "0x0000000000000000000000000000000000000000".to_string(), + taker: "0x0000000000000000000000000000000000000000".to_string(), + token_id: "123".to_string(), + maker_amount: "100".to_string(), + taker_amount: "50".to_string(), + expiration: "0".to_string(), + nonce: "0".to_string(), + fee_rate_bps: "0".to_string(), + side: "BUY".to_string(), + signature_type: 0, + signature: "0xdeadbeef".to_string(), + } + } + #[tokio::test(flavor = "multi_thread")] async fn test_client_creation() { let client = create_test_client("https://test.example.com"); @@ -2947,21 +2985,7 @@ mod tests { .await; let client = create_test_client_with_l2_auth(&server.url()); - let signed_order = crate::types::SignedOrderRequest { - salt: 1, - maker: "0x0000000000000000000000000000000000000000".to_string(), - signer: "0x0000000000000000000000000000000000000000".to_string(), - taker: "0x0000000000000000000000000000000000000000".to_string(), - token_id: "123".to_string(), - maker_amount: "100".to_string(), - taker_amount: "50".to_string(), - expiration: "0".to_string(), - nonce: "0".to_string(), - fee_rate_bps: "0".to_string(), - side: "BUY".to_string(), - signature_type: 0, - signature: "0xdeadbeef".to_string(), - }; + let signed_order = sample_signed_order(); let result = client .post_orders( @@ -2986,27 +3010,57 @@ mod tests { async fn test_post_orders_batch_too_many_validation() { let client = create_test_client_with_l2_auth("https://test.example.com"); let orders = (0..16) - .map(|_| crate::types::SignedOrderRequest { - salt: 1, - maker: "0x0000000000000000000000000000000000000000".to_string(), - signer: "0x0000000000000000000000000000000000000000".to_string(), - taker: "0x0000000000000000000000000000000000000000".to_string(), - token_id: "123".to_string(), - maker_amount: "100".to_string(), - taker_amount: "50".to_string(), - expiration: "0".to_string(), - nonce: "0".to_string(), - fee_rate_bps: "0".to_string(), - side: "BUY".to_string(), - signature_type: 0, - signature: "0xdeadbeef".to_string(), - }) + .map(|_| sample_signed_order()) .collect::>(); let result = client.post_orders(orders, crate::types::OrderType::GTC).await; assert!(matches!(result, Err(PolyfillError::Validation { .. }))); } + #[tokio::test(flavor = "multi_thread")] + async fn test_post_order_supports_fak() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/order") + .match_body(Matcher::Regex(r#""orderType":"FAK""#.to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"success":true,"orderID":"a"}"#) + .create_async() + .await; + + let client = create_test_client_with_l2_auth(&server.url()); + let result = client + .post_order(sample_signed_order(), crate::types::OrderType::FAK) + .await; + + mock.assert_async().await; + assert!(result.is_ok()); + assert_eq!(result.unwrap()["success"], true); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_post_orders_supports_fak() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/orders") + .match_body(Matcher::Regex(r#""orderType":"FAK""#.to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"[{"success":true,"orderID":"a"}]"#) + .create_async() + .await; + + let client = create_test_client_with_l2_auth(&server.url()); + let result = client + .post_orders(vec![sample_signed_order()], crate::types::OrderType::FAK) + .await; + + mock.assert_async().await; + assert!(result.is_ok()); + assert_eq!(result.unwrap()[0]["success"], true); + } + #[tokio::test(flavor = "multi_thread")] async fn test_create_or_derive_api_key() { let mut server = Server::new_async().await; diff --git a/src/types.rs b/src/types.rs index eaecc24..97e59e6 100644 --- a/src/types.rs +++ b/src/types.rs @@ -214,6 +214,7 @@ pub enum OrderType { #[default] GTC, FOK, + FAK, GTD, } @@ -222,6 +223,7 @@ impl OrderType { match self { OrderType::GTC => "GTC", OrderType::FOK => "FOK", + OrderType::FAK => "FAK", OrderType::GTD => "GTD", } } @@ -1657,3 +1659,19 @@ pub type Result = std::result::Result; pub type ApiCreds = ApiCredentials; pub type CreateOrderOptions = OrderOptions; pub type OrderArgs = OrderRequest; + +#[cfg(test)] +mod tests { + use super::OrderType; + + #[test] + fn test_order_type_fak_serde_and_string() { + assert_eq!(OrderType::FAK.as_str(), "FAK"); + + let json = serde_json::to_string(&OrderType::FAK).unwrap(); + assert_eq!(json, "\"FAK\""); + + let parsed: OrderType = serde_json::from_str("\"FAK\"").unwrap(); + assert_eq!(parsed, OrderType::FAK); + } +} From 84c3d269eabf607e7b0281e6899a9485fbb349e0 Mon Sep 17 00:00:00 2001 From: Andrey Kuznetsov Date: Thu, 12 Feb 2026 23:49:50 +0000 Subject: [PATCH 4/6] feat(client): use typed HTTP response models instead of serde_json::Value --- src/client.rs | 131 +++++++++++++++------------- src/lib.rs | 7 ++ src/orders.rs | 107 ++++++++++++++++++++--- src/types.rs | 166 ++++++++++++++++++++++++++++++++++-- tests/integration_tests.rs | 6 +- tests/order_posting_test.rs | 4 +- 6 files changed, 339 insertions(+), 82 deletions(-) diff --git a/src/client.rs b/src/client.rs index 516e5f1..c382072 100644 --- a/src/client.rs +++ b/src/client.rs @@ -14,7 +14,6 @@ use alloy_signer_local::PrivateKeySigner; use reqwest::header::HeaderName; use reqwest::Client; use reqwest::{Method, RequestBuilder}; -use rust_decimal::prelude::FromPrimitive; use rust_decimal::Decimal; use serde_json::Value; use std::str::FromStr; @@ -623,18 +622,8 @@ impl ClobClient { )); } - let tick_size_response: Value = response.json().await?; - let tick_size = tick_size_response["minimum_tick_size"] - .as_str() - .and_then(|s| Decimal::from_str(s).ok()) - .or_else(|| { - tick_size_response["minimum_tick_size"] - .as_f64() - .map(|f| Decimal::from_f64(f).unwrap_or(Decimal::ZERO)) - }) - .ok_or_else(|| PolyfillError::parse("Invalid tick size format", None))?; - - Ok(tick_size) + let tick_size_response: TickSizeResponse = response.json().await?; + Ok(tick_size_response.minimum_tick_size) } /// Get maker fee rate (in bps) for a token @@ -658,7 +647,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 @@ -820,12 +809,8 @@ impl ClobClient { )); } - let neg_risk_response: Value = response.json().await?; - let neg_risk = neg_risk_response["neg_risk"] - .as_bool() - .ok_or_else(|| PolyfillError::parse("Invalid neg risk format", None))?; - - Ok(neg_risk) + let neg_risk_response: NegRiskResponse = response.json().await?; + Ok(neg_risk_response.neg_risk) } /// Resolve tick size for an order @@ -953,7 +938,7 @@ impl ClobClient { .collect(), }; - order_builder.calculate_market_price(&levels, amount) + order_builder.calculate_market_price(side, &levels, amount) } /// Create a market order @@ -974,7 +959,7 @@ impl ClobClient { let extras = extras.unwrap_or_default(); let price = self - .calculate_market_price(&order_args.token_id, Side::BUY, order_args.amount) + .calculate_market_price(&order_args.token_id, order_args.side, order_args.amount) .await?; if !self.is_price_in_range( @@ -1000,7 +985,7 @@ impl ClobClient { &self, order: SignedOrderRequest, order_type: OrderType, - ) -> Result { + ) -> Result { let signer = self .signer .as_ref() @@ -1029,7 +1014,7 @@ impl ClobClient { return Err(PolyfillError::api(status, message)); } - Ok(response.json::().await?) + Ok(response.json::().await?) } /// Post multiple orders to the exchange in a single request @@ -1037,7 +1022,7 @@ impl ClobClient { &self, orders: Vec, order_type: OrderType, - ) -> Result { + ) -> Result> { if orders.is_empty() { return Err(PolyfillError::validation("orders cannot be empty")); } @@ -1076,7 +1061,9 @@ impl ClobClient { return Err(PolyfillError::api(status, message)); } - Ok(response.json::().await?) + Ok(response + .json::>() + .await?) } /// Create and post an order in one call with an explicit order type @@ -1084,13 +1071,16 @@ impl ClobClient { &self, order_args: &OrderArgs, order_type: OrderType, - ) -> Result { + ) -> Result { let order = self.create_order(order_args, None, None, None).await?; self.post_order(order, order_type).await } /// Create and post an order in one call (defaults to GTC) - pub async fn create_and_post_order(&self, order_args: &OrderArgs) -> Result { + pub async fn create_and_post_order( + &self, + order_args: &OrderArgs, + ) -> Result { self.create_and_post_order_with_type(order_args, OrderType::GTC) .await } @@ -1100,7 +1090,7 @@ impl ClobClient { &self, order_args: &[OrderArgs], order_type: OrderType, - ) -> Result { + ) -> Result> { if order_args.is_empty() { return Err(PolyfillError::validation("order_args cannot be empty")); } @@ -1114,13 +1104,16 @@ impl ClobClient { } /// Create and post multiple orders in one call (defaults to GTC) - pub async fn create_and_post_orders(&self, order_args: &[OrderArgs]) -> Result { + pub async fn create_and_post_orders( + &self, + order_args: &[OrderArgs], + ) -> Result> { self.create_and_post_orders_with_type(order_args, OrderType::GTC) .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() @@ -1143,11 +1136,16 @@ impl ClobClient { )); } - Ok(response.json::().await?) + Ok(response + .json::() + .await?) } /// 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() @@ -1168,11 +1166,13 @@ impl ClobClient { )); } - Ok(response.json::().await?) + Ok(response + .json::() + .await?) } /// Cancel all orders - pub async fn cancel_all(&self) -> Result { + pub async fn cancel_all(&self) -> Result { let signer = self .signer .as_ref() @@ -1182,7 +1182,7 @@ impl ClobClient { .as_ref() .ok_or_else(|| PolyfillError::auth("API credentials not set"))?; - let headers = create_l2_headers::(signer, api_creds, "DELETE", "/cancel-all", None)?; + let headers = create_l2_headers::<()>(signer, api_creds, "DELETE", "/cancel-all", None)?; let req = self.create_request_with_headers(Method::DELETE, "/cancel-all", headers.into_iter()); @@ -1194,7 +1194,9 @@ impl ClobClient { )); } - Ok(response.json::().await?) + Ok(response + .json::() + .await?) } /// Get open orders with optional filtering @@ -1292,7 +1294,7 @@ impl ClobClient { &self, trade_params: Option<&crate::types::TradeParams>, next_cursor: Option<&str>, - ) -> Result> { + ) -> Result> { let signer = self .signer .as_ref() @@ -1348,7 +1350,14 @@ impl ClobClient { next_cursor = new_cursor; let results = resp["data"].clone(); - output.push(results); + let trades = serde_json::from_value::>(results) + .map_err(|e| { + PolyfillError::parse( + format!("Failed to parse data from trades response: {}", e), + None, + ) + })?; + output.extend(trades); } Ok(output) @@ -1646,7 +1655,7 @@ impl ClobClient { &self, market: Option<&str>, asset_id: Option<&str>, - ) -> Result { + ) -> Result { let signer = self .signer .as_ref() @@ -1680,7 +1689,7 @@ impl ClobClient { .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; response - .json::() + .json::() .await .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None)) } @@ -1802,12 +1811,12 @@ impl ClobClient { .await .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; - let result: Value = response + let result: crate::types::OrderScoringResponse = response .json() .await .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None))?; - Ok(result["scoring"].as_bool().unwrap_or(false)) + Ok(result.scoring) } /// Check if multiple orders are scoring @@ -2357,9 +2366,11 @@ 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, ExtraOrderArgs, MakerOrder, Market, MarketOrderArgs, MarketsResponse, + MidpointResponse, NegRiskResponse, OrderBookSummary, OrderScoringResponse, OrderSummary, + PostOrderResponse, PriceHistoryPoint, PriceResponse, PricesHistoryInterval, + PricesHistoryResponse, Rewards, SpreadResponse, TickSizeResponse, Token, TradeResponse, + TraderSide, }; // Compatibility types that need to stay in client.rs @@ -2715,7 +2726,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_get_prices_history_interval_success() { let mut server = Server::new_async().await; - let mock_response = r#"{"history":[{"t":1}]}"#; + let mock_response = r#"{"history":[{"t":1,"p":"0.52"}]}"#; let mock = server .mock("GET", "/prices-history") @@ -2738,6 +2749,8 @@ mod tests { mock.assert_async().await; assert_eq!(response.history.len(), 1); + assert_eq!(response.history[0].t, 1); + assert_eq!(response.history[0].p, Decimal::from_str("0.52").unwrap()); } #[tokio::test(flavor = "multi_thread")] @@ -2980,7 +2993,7 @@ mod tests { .mock("POST", "/orders") .with_status(200) .with_header("content-type", "application/json") - .with_body(r#"{"success":true,"orderIDs":["a","b"]}"#) + .with_body(r#"[{"success":true,"orderID":"a"},{"success":true,"orderID":"b"}]"#) .create_async() .await; @@ -2996,24 +3009,28 @@ mod tests { mock.assert_async().await; assert!(result.is_ok()); - assert_eq!(result.unwrap()["success"], true); + let orders = result.unwrap(); + assert_eq!(orders.len(), 2); + assert!(orders.iter().all(|o| o.success)); } #[tokio::test(flavor = "multi_thread")] async fn test_post_orders_batch_empty_validation() { let client = create_test_client_with_l2_auth("https://test.example.com"); - let result = client.post_orders(Vec::new(), crate::types::OrderType::GTC).await; + let result = client + .post_orders(Vec::new(), crate::types::OrderType::GTC) + .await; assert!(matches!(result, Err(PolyfillError::Validation { .. }))); } #[tokio::test(flavor = "multi_thread")] async fn test_post_orders_batch_too_many_validation() { let client = create_test_client_with_l2_auth("https://test.example.com"); - let orders = (0..16) - .map(|_| sample_signed_order()) - .collect::>(); + let orders = (0..16).map(|_| sample_signed_order()).collect::>(); - let result = client.post_orders(orders, crate::types::OrderType::GTC).await; + let result = client + .post_orders(orders, crate::types::OrderType::GTC) + .await; assert!(matches!(result, Err(PolyfillError::Validation { .. }))); } @@ -3036,7 +3053,7 @@ mod tests { mock.assert_async().await; assert!(result.is_ok()); - assert_eq!(result.unwrap()["success"], true); + assert!(result.unwrap().success); } #[tokio::test(flavor = "multi_thread")] @@ -3058,7 +3075,7 @@ mod tests { mock.assert_async().await; assert!(result.is_ok()); - assert_eq!(result.unwrap()[0]["success"], true); + assert!(result.unwrap()[0].success); } #[tokio::test(flavor = "multi_thread")] @@ -3222,7 +3239,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/lib.rs b/src/lib.rs index d7c5b08..692be08 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -97,10 +97,12 @@ pub use crate::types::{ BatchPriceRequest, BatchPriceResponse, BookParams, + CancelOrdersResponse, ClientConfig, ClientResult, FeeRateResponse, FillEvent, + MakerOrder, Market, MarketSnapshot, MarketsResponse, @@ -114,9 +116,12 @@ pub use crate::types::{ OrderBookSummary, OrderDelta, OrderRequest, + OrderScoringResponse, OrderStatus, OrderSummary, OrderType, + PostOrderResponse, + PriceHistoryPoint, PriceResponse, PricesHistoryInterval, PricesHistoryResponse, @@ -143,6 +148,8 @@ pub use crate::types::{ Token, TokenPrice, TradeParams, + TradeResponse, + TraderSide, WssAuth, WssChannelType, WssSubscription, diff --git a/src/orders.rs b/src/orders.rs index 15cde0c..91e522c 100644 --- a/src/orders.rs +++ b/src/orders.rs @@ -193,32 +193,48 @@ 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); - - ( - decimal_to_token_u32(raw_maker_amt), - decimal_to_token_u32(raw_taker_amt), - ) + match side { + Side::BUY => { + let raw_maker_amt = amount.round_dp_with_strategy(round_config.size, ToZero); + let raw_taker_amt = raw_maker_amt / raw_price; + let raw_taker_amt = self.fix_amount_rounding(raw_taker_amt, round_config); + ( + 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 = raw_maker_amt * raw_price; + let raw_taker_amt = self.fix_amount_rounding(raw_taker_amt, round_config); + ( + decimal_to_token_u32(raw_maker_amt), + decimal_to_token_u32(raw_taker_amt), + ) + }, + } } /// Calculate market price from order book levels pub fn calculate_market_price( &self, + side: Side, positions: &[crate::types::BookLevel], amount_to_match: Decimal, ) -> Result { let mut sum = Decimal::ZERO; for level in positions { - sum += level.size * level.price; + sum += match side { + Side::BUY => level.size * level.price, + Side::SELL => level.size, + }; if sum >= amount_to_match { return Ok(level.price); } @@ -246,8 +262,12 @@ impl OrderBuilder { .tick_size .ok_or_else(|| PolyfillError::validation("Cannot create order without tick size"))?; - let (maker_amount, taker_amount) = - self.get_market_order_amounts(order_args.amount, price, &ROUNDING_CONFIG[&tick_size]); + let (maker_amount, taker_amount) = self.get_market_order_amounts( + order_args.side, + order_args.amount, + price, + &ROUNDING_CONFIG[&tick_size], + ); let neg_risk = options .neg_risk @@ -262,7 +282,7 @@ impl OrderBuilder { self.build_signed_order( order_args.token_id.clone(), - Side::BUY, + order_args.side, chain_id, exchange_address, maker_amount, @@ -437,4 +457,65 @@ mod tests { assert!(seed < u64::MAX); } } + + #[test] + fn test_calculate_market_price_respects_side_amount_semantics() { + let signer: PrivateKeySigner = + "0x1234567890123456789012345678901234567890123456789012345678901234" + .parse() + .unwrap(); + let builder = OrderBuilder::new(signer, None, None); + + let levels = vec![ + crate::types::BookLevel { + price: Decimal::from_str("0.50").unwrap(), + size: Decimal::from_str("10").unwrap(), + }, + crate::types::BookLevel { + price: Decimal::from_str("0.55").unwrap(), + size: Decimal::from_str("10").unwrap(), + }, + ]; + + // BUY amounts are quote-denominated: need 6 USDC -> first level (10 * 0.50 = 5) is not enough. + let buy_price = builder + .calculate_market_price(Side::BUY, &levels, Decimal::from_str("6").unwrap()) + .unwrap(); + assert_eq!(buy_price, Decimal::from_str("0.55").unwrap()); + + // SELL amounts are base-denominated: need 6 tokens -> first level (size 10) is enough. + let sell_price = builder + .calculate_market_price(Side::SELL, &levels, Decimal::from_str("6").unwrap()) + .unwrap(); + assert_eq!(sell_price, Decimal::from_str("0.50").unwrap()); + } + + #[test] + fn test_create_market_order_uses_input_side() { + let signer: PrivateKeySigner = + "0x1234567890123456789012345678901234567890123456789012345678901234" + .parse() + .unwrap(); + let builder = OrderBuilder::new(signer, None, None); + + let order = builder + .create_market_order( + 137, + &MarketOrderArgs { + token_id: "123".to_string(), + side: Side::SELL, + amount: Decimal::from_str("5").unwrap(), + }, + Decimal::from_str("0.40").unwrap(), + &ExtraOrderArgs::default(), + &OrderOptions { + tick_size: Some(Decimal::from_str("0.01").unwrap()), + neg_risk: Some(false), + fee_rate_bps: None, + }, + ) + .unwrap(); + + assert_eq!(order.side, "SELL"); + } } diff --git a/src/types.rs b/src/types.rs index 97e59e6..c9551f6 100644 --- a/src/types.rs +++ b/src/types.rs @@ -191,6 +191,21 @@ pub enum Side { SELL = 1, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum TraderSide { + Taker, + Maker, + #[serde(untagged)] + Unknown(String), +} + +impl Default for TraderSide { + fn default() -> Self { + Self::Unknown("UNKNOWN".to_string()) + } +} + impl Side { pub fn as_str(&self) -> &'static str { match self { @@ -526,6 +541,8 @@ impl Default for ExtraOrderArgs { #[derive(Debug, Clone)] pub struct MarketOrderArgs { pub token_id: String, + pub side: Side, + /// Quote amount for buys, base token amount for sells. pub amount: Decimal, } @@ -1108,6 +1125,131 @@ pub struct OpenOrder { pub created_at: u64, } +/// Response from posting a single order. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PostOrderResponse { + #[serde(default)] + pub error_msg: Option, + #[serde( + default, + deserialize_with = "crate::decode::deserializers::optional_decimal_from_string_default_on_error" + )] + pub making_amount: Option, + #[serde( + default, + deserialize_with = "crate::decode::deserializers::optional_decimal_from_string_default_on_error" + )] + pub taking_amount: Option, + #[serde(rename = "orderID")] + pub order_id: String, + #[serde(default)] + pub status: Option, + pub success: bool, + #[serde(default, alias = "transactionsHashes")] + pub transaction_hashes: Vec, + #[serde(default)] + pub trade_ids: Vec, +} + +/// Response from cancel endpoints. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CancelOrdersResponse { + #[serde( + default, + deserialize_with = "crate::decode::deserializers::vec_from_null" + )] + pub canceled: Vec, + #[serde(default, alias = "not_canceled")] + pub not_canceled: std::collections::HashMap, +} + +/// Trade item returned by `/data/trades`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct TradeResponse { + pub id: String, + #[serde(default)] + pub taker_order_id: Option, + pub market: String, + pub asset_id: String, + pub side: Side, + #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + pub size: Decimal, + #[serde( + default, + deserialize_with = "crate::decode::deserializers::optional_decimal_from_string" + )] + pub fee_rate_bps: Option, + #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + pub price: Decimal, + #[serde(default)] + pub status: Option, + #[serde( + default, + deserialize_with = "crate::decode::deserializers::optional_number_from_string" + )] + pub match_time: Option, + #[serde( + default, + deserialize_with = "crate::decode::deserializers::optional_number_from_string" + )] + pub last_update: Option, + #[serde(default)] + pub outcome: Option, + #[serde( + default, + deserialize_with = "crate::decode::deserializers::optional_number_from_string" + )] + pub bucket_index: Option, + #[serde(default)] + pub owner: Option, + #[serde(default)] + pub maker_address: Option, + #[serde( + default, + deserialize_with = "crate::decode::deserializers::vec_from_null" + )] + pub maker_orders: Vec, + #[serde(default)] + pub transaction_hash: Option, + #[serde(default)] + pub trader_side: TraderSide, + #[serde(default, alias = "err_msg")] + pub error_msg: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MakerOrder { + pub order_id: String, + #[serde(default)] + pub owner: Option, + #[serde(default)] + pub maker_address: Option, + #[serde( + default, + deserialize_with = "crate::decode::deserializers::optional_decimal_from_string" + )] + pub matched_amount: Option, + #[serde( + default, + deserialize_with = "crate::decode::deserializers::optional_decimal_from_string" + )] + pub price: Option, + #[serde( + default, + deserialize_with = "crate::decode::deserializers::optional_decimal_from_string" + )] + pub fee_rate_bps: Option, + #[serde(default)] + pub asset_id: Option, + #[serde(default)] + pub outcome: Option, + #[serde(default)] + pub side: Option, +} + /// Balance allowance information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BalanceAllowance { @@ -1256,24 +1398,29 @@ impl PricesHistoryInterval { } } -/// Raw response from `/prices-history`. +/// A single price-history datapoint from `/prices-history`. /// -/// We intentionally keep `history` entries as `serde_json::Value` because the upstream API has -/// no stable public schema here and currently may return empty history for many markets. +/// Mirrors the official Polymarket SDK shape (`t`, `p`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PriceHistoryPoint { + pub t: i64, + #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + pub p: Decimal, +} + +/// Response from `/prices-history`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PricesHistoryResponse { - pub history: Vec, + pub history: Vec, } #[derive(Debug, Deserialize)] pub struct SpreadResponse { - #[serde(with = "rust_decimal::serde::str")] pub spread: Decimal, } #[derive(Debug, Deserialize)] pub struct TickSizeResponse { - #[serde(with = "rust_decimal::serde::str")] pub minimum_tick_size: Decimal, } @@ -1282,6 +1429,11 @@ pub struct NegRiskResponse { pub neg_risk: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrderScoringResponse { + pub scoring: bool, +} + #[derive(Debug, Serialize, Deserialize)] pub struct BookParams { pub token_id: String, @@ -1376,7 +1528,7 @@ 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, + pub base_fee: u32, } /// Create RFQ request (Requester). diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index b15ebff..d7ecb7c 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -112,9 +112,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: {:?}", diff --git a/tests/order_posting_test.rs b/tests/order_posting_test.rs index f41deeb..03ee008 100644 --- a/tests/order_posting_test.rs +++ b/tests/order_posting_test.rs @@ -41,9 +41,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), } From 0280d537aeb753806c4a757f9abdf97dd95e1620 Mon Sep 17 00:00:00 2001 From: Andrey Kuznetsov Date: Fri, 13 Feb 2026 00:46:00 +0000 Subject: [PATCH 5/6] feat: add proxy wallet support and per-page pagination - Add sig_type/funder params to with_l2_headers() for proxy wallet order signing - Add get_trades_page() and get_orders_page() for per-page cursor pagination - Refactor get_trades/get_orders to use page methods internally --- src/client.rs | 277 +++++++++++++++++++++++++++++--------------------- src/lib.rs | 4 + 2 files changed, 166 insertions(+), 115 deletions(-) diff --git a/src/client.rs b/src/client.rs index c382072..46b3bdf 100644 --- a/src/client.rs +++ b/src/client.rs @@ -9,14 +9,13 @@ use crate::http_config::{ create_colocated_client, create_internet_client, create_optimized_client, prewarm_connections, }; use crate::types::{OrderOptions, PostOrder, SignedOrderRequest}; -use alloy_primitives::U256; +use alloy_primitives::{Address, U256}; use alloy_signer_local::PrivateKeySigner; use reqwest::header::HeaderName; use reqwest::Client; use reqwest::{Method, RequestBuilder}; use rust_decimal::Decimal; use serde_json::Value; -use std::str::FromStr; // Re-export types for compatibility pub use crate::types::{ApiCredentials as ApiCreds, OrderType, Side}; @@ -237,17 +236,23 @@ impl ClobClient { } /// Create a client with L2 headers (for API key authentication) + /// + /// `sig_type` and `funder` are optional parameters for proxy wallet support. + /// When using a Polymarket proxy wallet, pass `SigType::PolyProxy` and the + /// proxy wallet address as `funder`. pub fn with_l2_headers( host: &str, private_key: &str, chain_id: u64, api_creds: ApiCreds, + sig_type: Option, + funder: Option
, ) -> Self { let signer = private_key .parse::() .expect("Invalid private key"); - let order_builder = crate::orders::OrderBuilder::new(signer.clone(), None, None); + let order_builder = crate::orders::OrderBuilder::new(signer.clone(), sig_type, funder); let http_client = create_optimized_client().unwrap_or_else(|_| { reqwest::ClientBuilder::new() @@ -1199,19 +1204,15 @@ impl ClobClient { .await?) } - /// Get open orders with optional filtering + /// Fetch a single page of orders. /// - /// This retrieves all open orders for the authenticated user. You can filter by: - /// - Order ID (exact match) - /// - Asset/Token ID (all orders for a specific token) - /// - Market ID (all orders for a specific market) - /// - /// The response includes order status, fill information, and timestamps. - pub async fn get_orders( + /// Returns `(page_data, next_cursor)`. `next_cursor` is `None` when there are + /// no more pages. + pub async fn get_orders_page( &self, params: Option<&crate::types::OpenOrderParams>, next_cursor: Option<&str>, - ) -> Result> { + ) -> Result<(Vec, Option)> { let signer = self .signer .as_ref() @@ -1231,55 +1232,148 @@ impl ClobClient { Some(p) => p.to_query_params(), }; - let mut next_cursor = next_cursor.unwrap_or("MA==").to_string(); // INITIAL_CURSOR + let cursor = next_cursor.unwrap_or("MA=="); + + let req = self + .http_client + .request(method.clone(), format!("{}{}", self.base_url, endpoint)) + .query(&query_params) + .query(&[("next_cursor", cursor)]); + + let r = headers + .into_iter() + .fold(req, |r, (k, v)| r.header(HeaderName::from_static(k), v)); + + let resp = r + .send() + .await + .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))? + .json::() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None))?; + + let new_cursor = resp["next_cursor"] + .as_str() + .ok_or_else(|| PolyfillError::parse("Failed to parse next cursor".to_string(), None))? + .to_owned(); + + let results = resp["data"].clone(); + let orders = + serde_json::from_value::>(results).map_err(|e| { + PolyfillError::parse( + format!("Failed to parse data from order response: {}", e), + None, + ) + })?; + + let next = if new_cursor == "LTE=" { + None + } else { + Some(new_cursor) + }; + + Ok((orders, next)) + } + + /// Get open orders with optional filtering + /// + /// This retrieves all open orders for the authenticated user. You can filter by: + /// - Order ID (exact match) + /// - Asset/Token ID (all orders for a specific token) + /// - Market ID (all orders for a specific market) + /// + /// The response includes order status, fill information, and timestamps. + pub async fn get_orders( + &self, + params: Option<&crate::types::OpenOrderParams>, + next_cursor: Option<&str>, + ) -> Result> { + let mut cursor = next_cursor.map(|s| s.to_owned()); let mut output = Vec::new(); - while next_cursor != "LTE=" { - // END_CURSOR - let req = self - .http_client - .request(method.clone(), format!("{}{}", self.base_url, endpoint)) - .query(&query_params) - .query(&[("next_cursor", &next_cursor)]); - - let r = headers - .clone() - .into_iter() - .fold(req, |r, (k, v)| r.header(HeaderName::from_static(k), v)); - - let resp = r - .send() - .await - .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))? - .json::() - .await - .map_err(|e| { - PolyfillError::parse(format!("Failed to parse response: {}", e), None) - })?; - - let new_cursor = resp["next_cursor"] - .as_str() - .ok_or_else(|| { - PolyfillError::parse("Failed to parse next cursor".to_string(), None) - })? - .to_owned(); - - next_cursor = new_cursor; - - let results = resp["data"].clone(); - let orders = - serde_json::from_value::>(results).map_err(|e| { - PolyfillError::parse( - format!("Failed to parse data from order response: {}", e), - None, - ) - })?; - output.extend(orders); + loop { + let (page, next) = self.get_orders_page(params, cursor.as_deref()).await?; + output.extend(page); + match next { + Some(c) => cursor = Some(c), + None => break, + } } Ok(output) } + /// Fetch a single page of trades. + /// + /// Returns `(page_data, next_cursor)`. `next_cursor` is `None` when there are + /// no more pages. + pub async fn get_trades_page( + &self, + params: Option<&crate::types::TradeParams>, + next_cursor: Option<&str>, + ) -> Result<(Vec, Option)> { + 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 = "/data/trades"; + let headers = + create_l2_headers::(signer, api_creds, method.as_str(), endpoint, None)?; + + let query_params = match params { + None => Vec::new(), + Some(p) => p.to_query_params(), + }; + + let cursor = next_cursor.unwrap_or("MA=="); + + let req = self + .http_client + .request(method.clone(), format!("{}{}", self.base_url, endpoint)) + .query(&query_params) + .query(&[("next_cursor", cursor)]); + + let r = headers + .into_iter() + .fold(req, |r, (k, v)| r.header(HeaderName::from_static(k), v)); + + let resp = r + .send() + .await + .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))? + .json::() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None))?; + + let new_cursor = resp["next_cursor"] + .as_str() + .ok_or_else(|| PolyfillError::parse("Failed to parse next cursor".to_string(), None))? + .to_owned(); + + let results = resp["data"].clone(); + let trades = + serde_json::from_value::>(results).map_err(|e| { + PolyfillError::parse( + format!("Failed to parse data from trades response: {}", e), + None, + ) + })?; + + let next = if new_cursor == "LTE=" { + None + } else { + Some(new_cursor) + }; + + Ok((trades, next)) + } + /// Get trade history with optional filtering /// /// This retrieves historical trades for the authenticated user. You can filter by: @@ -1295,69 +1389,18 @@ impl ClobClient { trade_params: Option<&crate::types::TradeParams>, next_cursor: Option<&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 = "/data/trades"; - let headers = - create_l2_headers::(signer, api_creds, method.as_str(), endpoint, None)?; - - let query_params = match trade_params { - None => Vec::new(), - Some(p) => p.to_query_params(), - }; - - let mut next_cursor = next_cursor.unwrap_or("MA==").to_string(); // INITIAL_CURSOR + let mut cursor = next_cursor.map(|s| s.to_owned()); let mut output = Vec::new(); - while next_cursor != "LTE=" { - // END_CURSOR - let req = self - .http_client - .request(method.clone(), format!("{}{}", self.base_url, endpoint)) - .query(&query_params) - .query(&[("next_cursor", &next_cursor)]); - - let r = headers - .clone() - .into_iter() - .fold(req, |r, (k, v)| r.header(HeaderName::from_static(k), v)); - - let resp = r - .send() - .await - .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))? - .json::() - .await - .map_err(|e| { - PolyfillError::parse(format!("Failed to parse response: {}", e), None) - })?; - - let new_cursor = resp["next_cursor"] - .as_str() - .ok_or_else(|| { - PolyfillError::parse("Failed to parse next cursor".to_string(), None) - })? - .to_owned(); - - next_cursor = new_cursor; - - let results = resp["data"].clone(); - let trades = serde_json::from_value::>(results) - .map_err(|e| { - PolyfillError::parse( - format!("Failed to parse data from trades response: {}", e), - None, - ) - })?; - output.extend(trades); + loop { + let (page, next) = self + .get_trades_page(trade_params, cursor.as_deref()) + .await?; + output.extend(page); + match next { + Some(c) => cursor = Some(c), + None => break, + } } Ok(output) @@ -2422,6 +2465,8 @@ mod tests { "0x1234567890123456789012345678901234567890123456789012345678901234", 137, api_creds, + None, + None, ) } @@ -2472,6 +2517,8 @@ mod tests { "0x1234567890123456789012345678901234567890123456789012345678901234", 137, api_creds.clone(), + None, + None, ); assert_eq!(client.base_url, "https://test.example.com"); diff --git a/src/lib.rs b/src/lib.rs index 692be08..ed7397f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -158,6 +158,10 @@ pub use crate::types::{ // Re-export client pub use crate::client::{ClobClient, PolyfillClient}; +// Re-export order signing types (for proxy wallet support) +pub use crate::orders::SigType; +pub use alloy_primitives::Address; + // Re-export compatibility types (for easy migration from polymarket-rs-client) pub use crate::client::OrderArgs; From 4d6ba2ab5cb888e4f03b9dee6bf4a26342b32cfe Mon Sep 17 00:00:00 2001 From: Andrey Kuznetsov Date: Fri, 13 Feb 2026 01:39:26 +0000 Subject: [PATCH 6/6] feat(ws): extend TradeMessage with full trade execution fields TradeMessage was severely under-specified compared to the Polymarket WS API. Add missing fields needed by the engine: taker_order_id, maker_orders, trader_side, fee_rate_bps, outcome, owner, trade_owner, transaction_hash. Replace untyped status/msg_type strings with proper TradeMessageStatus and TradeMessageType enums. --- examples/demo.rs | 10 +++- src/decode.rs | 149 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 3 + src/types.rs | 67 ++++++++++++++++++++- 4 files changed, 225 insertions(+), 4 deletions(-) diff --git a/examples/demo.rs b/examples/demo.rs index 7fb6319..7b9a173 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -623,11 +623,19 @@ impl PolyfillDemo { side: Side::BUY, size: dec!(50.0), price: dec!(0.75), - status: Some("MATCHED".to_string()), + status: TradeMessageStatus::Matched, msg_type: None, last_update: None, matchtime: None, timestamp: None, + outcome: None, + owner: None, + trade_owner: None, + taker_order_id: None, + maker_orders: vec![], + fee_rate_bps: None, + transaction_hash: None, + trader_side: None, }), ]; diff --git a/src/decode.rs b/src/decode.rs index dab75c7..7bc363b 100644 --- a/src/decode.rs +++ b/src/decode.rs @@ -663,4 +663,153 @@ mod tests { let results: Vec = decoder.parse_json_stream(data).unwrap(); assert_eq!(results.len(), 2); } + + #[test] + fn test_trade_message_full_payload() { + use crate::types::{StreamMessage, TradeMessageStatus, TradeMessageType, TraderSide}; + + let json = r#"{ + "event_type": "trade", + "id": "trade-001", + "market": "0xabc123", + "asset_id": "asset-xyz", + "side": "BUY", + "size": "100.5", + "price": "0.65", + "status": "MATCHED", + "type": "TRADE", + "last_update": "1700000000", + "match_time": "1700000001", + "timestamp": "1700000002", + "outcome": "Yes", + "owner": "owner-key-123", + "trade_owner": "trader-key-456", + "taker_order_id": "taker-order-789", + "maker_orders": [ + { + "order_id": "maker-order-1", + "owner": "maker-owner-1", + "matched_amount": "50.25", + "price": "0.65", + "asset_id": "asset-xyz", + "outcome": "Yes" + } + ], + "fee_rate_bps": "2.5", + "transaction_hash": "0xdeadbeef", + "trader_side": "TAKER" + }"#; + + let msgs = parse_stream_messages(json).unwrap(); + assert_eq!(msgs.len(), 1); + + let StreamMessage::Trade(trade) = &msgs[0] else { + panic!("expected Trade variant"); + }; + + assert_eq!(trade.id, "trade-001"); + assert_eq!(trade.market, "0xabc123"); + assert_eq!(trade.asset_id, "asset-xyz"); + assert_eq!(trade.side, Side::BUY); + assert_eq!(trade.size, Decimal::from_str("100.5").unwrap()); + assert_eq!(trade.price, Decimal::from_str("0.65").unwrap()); + assert_eq!(trade.status, TradeMessageStatus::Matched); + assert_eq!(trade.msg_type, Some(TradeMessageType::Trade)); + assert_eq!(trade.last_update, Some(1700000000)); + assert_eq!(trade.matchtime, Some(1700000001)); + assert_eq!(trade.timestamp, Some(1700000002)); + assert_eq!(trade.outcome.as_deref(), Some("Yes")); + assert_eq!(trade.owner.as_deref(), Some("owner-key-123")); + assert_eq!(trade.trade_owner.as_deref(), Some("trader-key-456")); + assert_eq!(trade.taker_order_id.as_deref(), Some("taker-order-789")); + assert_eq!(trade.maker_orders.len(), 1); + assert_eq!(trade.maker_orders[0].order_id, "maker-order-1"); + assert_eq!(trade.fee_rate_bps, Some(Decimal::from_str("2.5").unwrap())); + assert_eq!(trade.transaction_hash.as_deref(), Some("0xdeadbeef")); + assert_eq!(trade.trader_side, Some(TraderSide::Taker)); + } + + #[test] + fn test_trade_message_minimal_payload() { + use crate::types::StreamMessage; + + // Only mandatory fields — all new optional fields absent. + let json = r#"{ + "event_type": "trade", + "id": "trade-minimal", + "market": "0xdef", + "asset_id": "asset-min", + "side": "SELL", + "size": "10", + "price": "0.50" + }"#; + + let msgs = parse_stream_messages(json).unwrap(); + assert_eq!(msgs.len(), 1); + + let StreamMessage::Trade(trade) = &msgs[0] else { + panic!("expected Trade variant"); + }; + + assert_eq!(trade.id, "trade-minimal"); + assert_eq!(trade.side, Side::SELL); + // All optional fields should be None / empty / default. + assert!(trade.msg_type.is_none()); + assert!(trade.outcome.is_none()); + assert!(trade.owner.is_none()); + assert!(trade.trade_owner.is_none()); + assert!(trade.taker_order_id.is_none()); + assert!(trade.maker_orders.is_empty()); + assert!(trade.fee_rate_bps.is_none()); + assert!(trade.transaction_hash.is_none()); + assert!(trade.trader_side.is_none()); + } + + #[test] + fn test_trade_message_status_lifecycle() { + use crate::types::{StreamMessage, TradeMessageStatus}; + + for (status_str, expected) in [ + ("MATCHED", TradeMessageStatus::Matched), + ("matched", TradeMessageStatus::Matched), + ("Matched", TradeMessageStatus::Matched), + ("MINED", TradeMessageStatus::Mined), + ("mined", TradeMessageStatus::Mined), + ("CONFIRMED", TradeMessageStatus::Confirmed), + ("confirmed", TradeMessageStatus::Confirmed), + ] { + let json = format!( + r#"{{ + "event_type": "trade", + "id": "t1", "market": "m", "asset_id": "a", + "side": "BUY", "size": "1", "price": "0.5", + "status": "{status_str}" + }}"# + ); + let msgs = parse_stream_messages(&json).unwrap(); + let StreamMessage::Trade(trade) = &msgs[0] else { + panic!("expected Trade"); + }; + assert_eq!(trade.status, expected, "failed for status_str={status_str}"); + } + } + + #[test] + fn test_trade_message_null_maker_orders() { + use crate::types::StreamMessage; + + // API sometimes sends `null` instead of `[]`. + let json = r#"{ + "event_type": "trade", + "id": "t1", "market": "m", "asset_id": "a", + "side": "BUY", "size": "1", "price": "0.5", + "maker_orders": null + }"#; + + let msgs = parse_stream_messages(json).unwrap(); + let StreamMessage::Trade(trade) = &msgs[0] else { + panic!("expected Trade"); + }; + assert!(trade.maker_orders.is_empty()); + } } diff --git a/src/lib.rs b/src/lib.rs index ed7397f..9dede6d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -147,6 +147,9 @@ pub use crate::types::{ TickSizeResponse, Token, TokenPrice, + TradeMessage, + TradeMessageStatus, + TradeMessageType, TradeParams, TradeResponse, TraderSide, diff --git a/src/types.rs b/src/types.rs index c9551f6..c463699 100644 --- a/src/types.rs +++ b/src/types.rs @@ -206,6 +206,36 @@ impl Default for TraderSide { } } +/// Trade lifecycle status (Matched → Mined → Confirmed). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TradeMessageStatus { + #[serde(alias = "matched", alias = "MATCHED")] + Matched, + #[serde(alias = "mined", alias = "MINED")] + Mined, + #[serde(alias = "confirmed", alias = "CONFIRMED")] + Confirmed, + /// Forward-compatible catch-all for unknown statuses. + #[serde(untagged)] + Unknown(String), +} + +impl Default for TradeMessageStatus { + fn default() -> Self { + Self::Unknown("UNKNOWN".to_string()) + } +} + +/// Trade message type discriminator. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TradeMessageType { + #[serde(alias = "trade", alias = "TRADE")] + Trade, + /// Forward-compatible catch-all. + #[serde(untagged)] + Unknown(String), +} + impl Side { pub fn as_str(&self) -> &'static str { match self { @@ -908,7 +938,7 @@ pub struct EventMessage { pub description: String, } -/// User trade execution message. +/// User trade execution message (authenticated WebSocket channel). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TradeMessage { pub id: String, @@ -917,10 +947,11 @@ pub struct TradeMessage { pub side: Side, pub size: Decimal, pub price: Decimal, + /// Trade lifecycle status (Matched → Mined → Confirmed). #[serde(default)] - pub status: Option, + pub status: TradeMessageStatus, #[serde(rename = "type", default)] - pub msg_type: Option, + pub msg_type: Option, #[serde( default, deserialize_with = "crate::decode::deserializers::optional_number_from_string" @@ -937,6 +968,36 @@ pub struct TradeMessage { deserialize_with = "crate::decode::deserializers::optional_number_from_string" )] pub timestamp: Option, + /// Outcome (e.g. "Yes" / "No"). + #[serde(default)] + pub outcome: Option, + /// API key of the event owner. + #[serde(default)] + pub owner: Option, + /// API key of the trade owner. + #[serde(default)] + pub trade_owner: Option, + /// Taker order ID. + #[serde(default)] + pub taker_order_id: Option, + /// Maker order details. + #[serde( + default, + deserialize_with = "crate::decode::deserializers::vec_from_null" + )] + pub maker_orders: Vec, + /// Fee rate in basis points. + #[serde( + default, + deserialize_with = "crate::decode::deserializers::optional_decimal_from_string" + )] + pub fee_rate_bps: Option, + /// On-chain transaction hash. + #[serde(default)] + pub transaction_hash: Option, + /// Whether user was maker or taker. + #[serde(default)] + pub trader_side: Option, } /// User order update message.