From e5994d04dd0dedaf1da0991a572e7e71b297402b Mon Sep 17 00:00:00 2001 From: floor-licker Date: Mon, 27 Apr 2026 22:36:57 -0300 Subject: [PATCH] fix: align V2 client with official SDK fee behavior --- src/client.rs | 154 +++++++++++++++++++++++++++------- src/decode.rs | 10 +++ src/orders.rs | 130 ++++++++++++++++++---------- src/types.rs | 40 +++++++-- tests/integration_tests.rs | 5 +- tests/ws_integration_tests.rs | 4 +- 6 files changed, 263 insertions(+), 80 deletions(-) diff --git a/src/client.rs b/src/client.rs index b36d7ec..4524c95 100644 --- a/src/client.rs +++ b/src/client.rs @@ -5,13 +5,11 @@ use crate::auth::{create_l1_headers, create_l2_headers}; use crate::errors::{PolyfillError, Result}; -use crate::http_config::{ - create_colocated_client, create_internet_client, prewarm_connections, -}; +use crate::http_config::{create_colocated_client, create_internet_client, prewarm_connections}; use crate::types::{ - CancelOrdersResponse, ClientConfig, ClobMarketInfo, CreateOrderOptions, MarketOrderArgs, - OrderArgs, OrderType, PostOrder, PostOrderOptions, PostOrderResponse, SignedOrderRequest, - Side, + BuilderFeeRateResponse, CancelOrdersResponse, ClientConfig, ClobMarketInfo, CreateOrderOptions, + MarketOrderArgs, OrderArgs, OrderType, PostOrder, PostOrderOptions, PostOrderResponse, Side, + SignedOrderRequest, }; use alloy_primitives::U256; use alloy_signer_local::PrivateKeySigner; @@ -96,7 +94,10 @@ impl ClobClient { }); let connection_manager = Some(std::sync::Arc::new( - crate::connection_manager::ConnectionManager::new(http_client.clone(), host.to_string()), + crate::connection_manager::ConnectionManager::new( + http_client.clone(), + host.to_string(), + ), )); let buffer_pool = std::sync::Arc::new(crate::buffer_pool::BufferPool::new(512 * 1024, 10)); @@ -455,6 +456,41 @@ impl ClobClient { .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {e}"), None)) } + /// Get V2 builder fee rates for a bytes32 builder code. + pub async fn get_builder_fee_rate(&self, builder_code: &str) -> Result { + crate::orders::validate_bytes32_hex("builder_code", builder_code)?; + + 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 endpoint = format!("/fees/builder-fees/{builder_code}"); + let headers = create_l2_headers::(signer, api_creds, "GET", &endpoint, None)?; + let req = self.create_request_with_headers(Method::GET, &endpoint, headers.into_iter()); + + let response = req.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 get builder fee rate".to_string() + } else { + format!("Failed to get builder fee rate: {body}") + }; + return Err(PolyfillError::api(status, message)); + } + + response + .json::() + .await + .map_err(|e| PolyfillError::parse(format!("Failed to parse response: {e}"), None)) + } + fn validate_prices_history_asset_id(asset_id: &str) -> Result<()> { if asset_id.is_empty() { return Err(PolyfillError::validation( @@ -974,12 +1010,21 @@ impl ClobClient { if order_args.side == Side::BUY { if let Some(user_balance) = order_args.user_usdc_balance { - let market_info = self.get_clob_market_info_for_token(&order_args.token_id).await?; + let market_info = self + .get_clob_market_info_for_token(&order_args.token_id) + .await?; let fee_details = market_info.fd.unwrap_or(crate::types::ClobFeeDetails { r: Decimal::ZERO, e: 0, to: false, }); + let builder_taker_fee_rate = match order_args.builder_code.as_deref() { + Some(code) if code != crate::orders::BYTES32_ZERO => { + let rate = self.get_builder_fee_rate(code).await?; + Decimal::from(rate.builder_taker_fee_rate_bps) / Decimal::from(10_000_u32) + }, + _ => Decimal::ZERO, + }; order_args.amount = crate::orders::adjust_buy_amount_for_fees( order_args.amount, @@ -987,11 +1032,7 @@ impl ClobClient { user_balance, fee_details.r, fee_details.e, - if order_args.builder_code.is_some() { - market_info.tbf - } else { - Decimal::ZERO - }, + builder_taker_fee_rate, )?; } } @@ -1029,6 +1070,17 @@ impl ClobClient { "post_only is not supported for FOK/FAK orders", )); } + let expiration = order.expiration.parse::().map_err(|e| { + PolyfillError::validation(format!( + "Invalid order expiration '{}': {e}", + order.expiration + )) + })?; + if expiration > 0 && options.order_type != OrderType::GTD { + return Err(PolyfillError::validation( + "expiration is only supported for GTD orders", + )); + } // Owner field must reference the credential principal identifier // to maintain consistency with the authentication context layer @@ -2362,8 +2414,7 @@ mod tests { base_url: base_url.to_string(), chain: 137, private_key: Some( - "0x1234567890123456789012345678901234567890123456789012345678901234" - .to_string(), + "0x1234567890123456789012345678901234567890123456789012345678901234".to_string(), ), ..ClientConfig::default() }) @@ -2382,8 +2433,7 @@ mod tests { base_url: base_url.to_string(), chain: 137, private_key: Some( - "0x1234567890123456789012345678901234567890123456789012345678901234" - .to_string(), + "0x1234567890123456789012345678901234567890123456789012345678901234".to_string(), ), api_credentials: Some(api_creds), ..ClientConfig::default() @@ -2403,12 +2453,10 @@ mod tests { side: "BUY".to_string(), signature_type: 0, timestamp: "1713916800000".to_string(), - metadata: - "0x0000000000000000000000000000000000000000000000000000000000000000" - .to_string(), - builder: - "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - .to_string(), + metadata: "0x0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + builder: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), signature: "0xdeadbeef".to_string(), } } @@ -2441,8 +2489,7 @@ mod tests { base_url: "https://test.example.com".to_string(), chain: 137, private_key: Some( - "0x1234567890123456789012345678901234567890123456789012345678901234" - .to_string(), + "0x1234567890123456789012345678901234567890123456789012345678901234".to_string(), ), api_credentials: Some(api_creds.clone()), ..ClientConfig::default() @@ -3121,13 +3168,11 @@ mod tests { .with_header("content-type", "application/json") .with_body( r#"{ + "c":"0x1111111111111111111111111111111111111111111111111111111111111111", "gst":"ready", - "r":{}, "t":[{"t":"123","o":"YES"},{"t":"456","o":"NO"}], "mos":"5", "mts":"0.01", - "mbf":"0", - "tbf":"15", "rfqe":true, "itode":false, "ibce":false, @@ -3143,13 +3188,42 @@ mod tests { let info = client.get_clob_market_info("condition-1").await.unwrap(); mock.assert_async().await; + assert_eq!( + info.c.as_deref(), + Some("0x1111111111111111111111111111111111111111111111111111111111111111") + ); assert_eq!(info.t.len(), 2); assert_eq!(info.mos, Decimal::from_str("5").unwrap()); assert_eq!(info.mts, Decimal::from_str("0.01").unwrap()); - assert_eq!(info.tbf, Decimal::from_str("15").unwrap()); + assert_eq!(info.tbf, Decimal::ZERO); assert_eq!(info.fd.unwrap().e, 2); } + #[tokio::test(flavor = "multi_thread")] + async fn test_get_builder_fee_rate_uses_v2_endpoint() { + let mut server = Server::new_async().await; + let builder_code = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let mock = server + .mock("GET", format!("/fees/builder-fees/{builder_code}").as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "builder_maker_fee_rate_bps": 5, + "builder_taker_fee_rate_bps": 12 + }"#, + ) + .create_async() + .await; + + let client = create_test_client_with_l2_auth(&server.url()); + let response = client.get_builder_fee_rate(builder_code).await.unwrap(); + + mock.assert_async().await; + assert_eq!(response.builder_maker_fee_rate_bps, 5); + assert_eq!(response.builder_taker_fee_rate_bps, 12); + } + #[tokio::test(flavor = "multi_thread")] async fn test_post_order_uses_v2_wire_shape_and_typed_response() { let mut server = Server::new_async().await; @@ -3173,7 +3247,7 @@ mod tests { "signature": "0xdeadbeef" }, "owner": "test_key", - "orderType": "GTC", + "orderType": "GTD", "postOnly": true, "deferExec": true }) @@ -3201,7 +3275,7 @@ mod tests { .post_order( sample_signed_order(), Some(&PostOrderOptions { - order_type: OrderType::GTC, + order_type: OrderType::GTD, post_only: true, defer_exec: true, }), @@ -3235,6 +3309,24 @@ mod tests { assert!(matches!(err, PolyfillError::Validation { .. })); } + #[tokio::test(flavor = "multi_thread")] + async fn test_post_order_rejects_expiration_for_non_gtd() { + let client = create_test_client_with_l2_auth("https://test.example.com"); + let err = client + .post_order( + sample_signed_order(), + Some(&PostOrderOptions { + order_type: OrderType::GTC, + post_only: false, + defer_exec: false, + }), + ) + .await + .unwrap_err(); + + assert!(matches!(err, PolyfillError::Validation { .. })); + } + #[tokio::test(flavor = "multi_thread")] async fn test_cancel_endpoints_parse_typed_responses() { let mut server = Server::new_async().await; diff --git a/src/decode.rs b/src/decode.rs index dab75c7..f85e00c 100644 --- a/src/decode.rs +++ b/src/decode.rs @@ -186,6 +186,16 @@ pub mod deserializers { serde::de::Error::custom("Expected decimal as string/number, got null/empty string") }) } + + /// Deserialize a Decimal from string/number/null, defaulting missing-ish values to zero. + pub fn decimal_from_string_or_zero<'de, D>( + deserializer: D, + ) -> std::result::Result + where + D: Deserializer<'de>, + { + Ok(optional_decimal_from_string(deserializer)?.unwrap_or(Decimal::ZERO)) + } } /// Raw API response types for efficient parsing diff --git a/src/orders.rs b/src/orders.rs index 0be1e0a..da7bbe1 100644 --- a/src/orders.rs +++ b/src/orders.rs @@ -5,20 +5,20 @@ use crate::auth::{sign_order_message, SignedOrderMessage}; use crate::errors::{PolyfillError, Result}; -use crate::types::{CreateOrderOptions, MarketOrderArgs, OrderArgs, OrderType, Side, SignedOrderRequest}; +use crate::types::{ + CreateOrderOptions, MarketOrderArgs, OrderArgs, OrderType, Side, SignedOrderRequest, +}; use alloy_primitives::{Address, B256, U256}; use alloy_signer_local::PrivateKeySigner; use rand::Rng; use rust_decimal::Decimal; -use rust_decimal::prelude::{FromPrimitive, ToPrimitive}; use rust_decimal::RoundingStrategy::{AwayFromZero, MidpointTowardZero, ToZero}; use std::collections::HashMap; use std::str::FromStr; use std::sync::LazyLock; use std::time::{SystemTime, UNIX_EPOCH}; -pub const BYTES32_ZERO: &str = - "0x0000000000000000000000000000000000000000000000000000000000000000"; +pub const BYTES32_ZERO: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; /// Signature types for orders #[derive(Copy, Clone)] @@ -133,7 +133,7 @@ fn parse_round_config(tick_size: Decimal) -> Result<&'static RoundConfig> { .ok_or_else(|| PolyfillError::validation(format!("Unsupported tick size {tick_size}"))) } -fn validate_bytes32_hex(field: &str, value: &str) -> Result<()> { +pub(crate) fn validate_bytes32_hex(field: &str, value: &str) -> Result<()> { if value == BYTES32_ZERO { return Ok(()); } @@ -176,39 +176,47 @@ pub fn adjust_buy_amount_for_fees( user_usdc_balance: Decimal, fee_rate: Decimal, fee_exponent: u32, - builder_taker_fee_rate_bps: Decimal, + builder_taker_fee_rate: Decimal, ) -> Result { - let price_f64 = price - .to_f64() - .ok_or_else(|| PolyfillError::validation(format!("Invalid price {price}")))?; - let amount_f64 = amount - .to_f64() - .ok_or_else(|| PolyfillError::validation(format!("Invalid amount {amount}")))?; - let user_balance_f64 = user_usdc_balance.to_f64().ok_or_else(|| { - PolyfillError::validation(format!("Invalid user_usdc_balance {user_usdc_balance}")) - })?; - let fee_rate_f64 = fee_rate - .to_f64() - .ok_or_else(|| PolyfillError::validation(format!("Invalid fee rate {fee_rate}")))?; - let builder_rate_f64 = builder_taker_fee_rate_bps - .to_f64() - .ok_or_else(|| PolyfillError::validation(format!( - "Invalid builder taker fee rate {builder_taker_fee_rate_bps}" - )))? - / 10_000.0; + if price <= Decimal::ZERO { + return Err(PolyfillError::validation( + "Market buy fee adjustment requires a positive price", + )); + } - let platform_fee_rate = fee_rate_f64 * (price_f64 * (1.0 - price_f64)).powi(fee_exponent as i32); - let platform_fee = (amount_f64 / price_f64) * platform_fee_rate; - let total_cost = amount_f64 + platform_fee + amount_f64 * builder_rate_f64; + let base = price * (Decimal::ONE - price); + let base_f64: f64 = base + .try_into() + .map_err(|_| PolyfillError::validation(format!("Invalid fee base {base}")))?; + let exp_f64: f64 = Decimal::from(fee_exponent) + .try_into() + .map_err(|_| PolyfillError::validation(format!("Invalid fee exponent {fee_exponent}")))?; + let platform_fee_rate = fee_rate + * Decimal::try_from(base_f64.powf(exp_f64)).map_err(|_| { + PolyfillError::validation(format!( + "Invalid platform fee rate for price {price} and exponent {fee_exponent}" + )) + })?; - let adjusted = if user_balance_f64 <= total_cost { - user_balance_f64 / (1.0 + platform_fee_rate / price_f64 + builder_rate_f64) + let platform_fee = amount / price * platform_fee_rate; + let total_cost = amount + platform_fee + amount * builder_taker_fee_rate; + + let raw = if user_usdc_balance <= total_cost { + let divisor = Decimal::ONE + platform_fee_rate / price + builder_taker_fee_rate; + user_usdc_balance / divisor } else { - amount_f64 + amount }; - Decimal::from_f64(adjusted) - .ok_or_else(|| PolyfillError::validation("Adjusted market buy amount is out of range")) + let adjusted = raw.trunc_with_scale(6); + if adjusted.is_zero() { + return Err(PolyfillError::validation(format!( + "user_usdc_balance {user_usdc_balance} too small to cover fees at price {price}; \ + fee-adjusted amount truncated to zero" + ))); + } + + Ok(adjusted) } impl OrderBuilder { @@ -290,7 +298,8 @@ impl OrderBuilder { match side { Side::BUY => { let raw_maker_amt = amount.round_dp_with_strategy(round_config.size, ToZero); - let raw_taker_amt = self.fix_amount_rounding(raw_maker_amt / raw_price, round_config); + let raw_taker_amt = + self.fix_amount_rounding(raw_maker_amt / raw_price, round_config); ( decimal_to_token_u32(raw_maker_amt), @@ -299,7 +308,8 @@ impl OrderBuilder { }, Side::SELL => { let raw_maker_amt = amount.round_dp_with_strategy(round_config.size, ToZero); - let raw_taker_amt = self.fix_amount_rounding(raw_maker_amt * raw_price, round_config); + let raw_taker_amt = + self.fix_amount_rounding(raw_maker_amt * raw_price, round_config); ( decimal_to_token_u32(raw_maker_amt), @@ -357,9 +367,9 @@ impl OrderBuilder { )); } - let tick_size = options.tick_size.ok_or_else(|| { - PolyfillError::validation("Cannot create order without tick size") - })?; + let tick_size = options + .tick_size + .ok_or_else(|| PolyfillError::validation("Cannot create order without tick size"))?; let round_config = parse_round_config(tick_size)?; let (maker_amount, taker_amount) = @@ -396,9 +406,9 @@ impl OrderBuilder { order_args: &OrderArgs, options: &CreateOrderOptions, ) -> Result { - let tick_size = options.tick_size.ok_or_else(|| { - PolyfillError::validation("Cannot create order without tick size") - })?; + let tick_size = options + .tick_size + .ok_or_else(|| PolyfillError::validation("Cannot create order without tick size"))?; let round_config = parse_round_config(tick_size)?; let (maker_amount, taker_amount) = self.get_order_amounts( @@ -541,8 +551,14 @@ mod tests { fn test_get_contract_config() { // Test Polygon mainnet let config = get_contract_config(137, false).expect("polygon config"); - assert_eq!(config.exchange, "0xE111180000d2663C0091e4f400237545B87B996B"); - assert_eq!(config.collateral, "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"); + assert_eq!( + config.exchange, + "0xE111180000d2663C0091e4f400237545B87B996B" + ); + assert_eq!( + config.collateral, + "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" + ); assert_eq!( config.conditional_tokens, "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" @@ -641,6 +657,36 @@ mod tests { assert!(!order.timestamp.is_empty()); } + #[test] + fn test_adjust_buy_amount_for_fees_uses_builder_rate_decimal() { + let adjusted = adjust_buy_amount_for_fees( + Decimal::from_str("100").unwrap(), + Decimal::from_str("0.5").unwrap(), + Decimal::from_str("100").unwrap(), + Decimal::ZERO, + 0, + Decimal::from_str("0.01").unwrap(), + ) + .unwrap(); + + assert_eq!(adjusted, Decimal::from_str("99.009900").unwrap()); + } + + #[test] + fn test_adjust_buy_amount_for_fees_rejects_zero_after_truncation() { + let err = adjust_buy_amount_for_fees( + Decimal::from_str("1").unwrap(), + Decimal::from_str("0.5").unwrap(), + Decimal::from_str("0.0000009").unwrap(), + Decimal::ZERO, + 0, + Decimal::ZERO, + ) + .unwrap_err(); + + assert!(matches!(err, PolyfillError::Validation { .. })); + } + #[test] fn test_market_order_amounts_differ_for_buy_and_sell() { let builder = test_builder(); diff --git a/src/types.rs b/src/types.rs index 918b32a..c0df8f5 100644 --- a/src/types.rs +++ b/src/types.rs @@ -669,6 +669,7 @@ pub struct ClobTokenInfo { /// Fee details returned by `GET /clob-markets/{condition_id}`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ClobFeeDetails { + #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] pub r: Decimal, pub e: u32, #[serde(default)] @@ -678,17 +679,33 @@ pub struct ClobFeeDetails { /// CLOB market info returned by `GET /clob-markets/{condition_id}`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ClobMarketInfo { + #[serde(default)] + pub c: Option, #[serde(default)] pub gst: Option, + #[serde(default)] pub r: serde_json::Value, + #[serde(default)] pub t: Vec, - #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + #[serde( + default, + deserialize_with = "crate::decode::deserializers::decimal_from_string_or_zero" + )] pub mos: Decimal, - #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + #[serde( + default, + deserialize_with = "crate::decode::deserializers::decimal_from_string_or_zero" + )] pub mts: Decimal, - #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + #[serde( + default, + deserialize_with = "crate::decode::deserializers::decimal_from_string_or_zero" + )] pub mbf: Decimal, - #[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")] + #[serde( + default, + deserialize_with = "crate::decode::deserializers::decimal_from_string_or_zero" + )] pub tbf: Decimal, #[serde(default)] pub rfqe: bool, @@ -700,10 +717,23 @@ pub struct ClobMarketInfo { pub nr: Option, #[serde(default)] pub fd: Option, - #[serde(default, deserialize_with = "crate::decode::deserializers::optional_number_from_string")] + #[serde( + default, + deserialize_with = "crate::decode::deserializers::optional_number_from_string" + )] pub oas: Option, } +/// Builder fee response returned by `GET /fees/builder-fees/{builder_code}`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BuilderFeeRateResponse { + #[serde(alias = "builder_maker_fee_rate_bps")] + pub builder_maker_fee_rate_bps: u32, + #[serde(alias = "builder_taker_fee_rate_bps")] + pub builder_taker_fee_rate_bps: u32, +} + /// Market information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Market { diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 1332fbf..79f775e 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -31,7 +31,10 @@ fn bootstrap_client(private_key: &str) -> ClobClient { .expect("failed to build bootstrap client") } -fn authenticated_client(private_key: String, api_credentials: polyfill_rs::ApiCredentials) -> ClobClient { +fn authenticated_client( + private_key: String, + api_credentials: polyfill_rs::ApiCredentials, +) -> ClobClient { ClobClient::from_config(ClientConfig { base_url: HOST.to_string(), chain: CHAIN_ID, diff --git a/tests/ws_integration_tests.rs b/tests/ws_integration_tests.rs index 33d8a50..692e3e1 100644 --- a/tests/ws_integration_tests.rs +++ b/tests/ws_integration_tests.rs @@ -8,7 +8,9 @@ #![cfg(feature = "stream")] use futures::StreamExt; -use polyfill_rs::{ClientConfig, ClobClient, OrderBookManager, WebSocketStream, WsBookUpdateProcessor}; +use polyfill_rs::{ + ClientConfig, ClobClient, OrderBookManager, WebSocketStream, WsBookUpdateProcessor, +}; use std::env; use std::time::Duration;