fix: align V2 client with official SDK fee behavior

This commit is contained in:
floor-licker
2026-04-27 22:36:57 -03:00
parent e78ae17286
commit e5994d04dd
6 changed files with 263 additions and 80 deletions
+123 -31
View File
@@ -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<BuilderFeeRateResponse> {
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::<Value>(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::<BuilderFeeRateResponse>()
.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::<u64>().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;
+10
View File
@@ -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<Decimal, D::Error>
where
D: Deserializer<'de>,
{
Ok(optional_decimal_from_string(deserializer)?.unwrap_or(Decimal::ZERO))
}
}
/// Raw API response types for efficient parsing
+88 -42
View File
@@ -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<Decimal> {
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<SignedOrderRequest> {
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();
+35 -5
View File
@@ -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<String>,
#[serde(default)]
pub gst: Option<String>,
#[serde(default)]
pub r: serde_json::Value,
#[serde(default)]
pub t: Vec<ClobTokenInfo>,
#[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<bool>,
#[serde(default)]
pub fd: Option<ClobFeeDetails>,
#[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<u64>,
}
/// 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 {
+4 -1
View File
@@ -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,
+3 -1
View File
@@ -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;