Summary
polymarket_client_sdk_v2 = "0.5.1" strict-deserializes MakerOrder.fee_rate_bps as Decimal, but the V2 production CLOB server (https://clob.polymarket.com) returns this field as the empty string "" for some trades. This fails the entire Page<TradeResponse> deserialization and prevents any caller from retrieving trade history when the response includes such a record.
Where it fails
- File:
src/clob/types/response.rs
- Field:
MakerOrder.fee_rate_bps: Decimal (line ~521)
pub struct MakerOrder {
pub order_id: String,
pub owner: ApiKey,
pub maker_address: Address,
pub matched_amount: Decimal,
pub price: Decimal,
pub fee_rate_bps: Decimal, // <-- V2 server returns ""
...
}
Reproducer
Authenticate against https://clob.polymarket.com and call client.trades(TradesRequest::builder().maker_address(addr).build(), None) for any address that has trade history including this shape.
Diagnostic (with tracing feature enabled)
ERROR deserialization failed
type_name=core::option::Option<polymarket_client_sdk_v2::clob::types::response::Page<polymarket_client_sdk_v2::clob::types::response::TradeResponse>>
path=data[0].maker_orders[0].fee_rate_bps
value=""
error=invalid value: string "", expected a Decimal type representing a fixed-point number
at line 1 column 406
Why V2 likely returns "" here
Per the V2 migration guide:
Fees: No longer embedded in signed orders. Protocol determines fees at match time per market.
So per-MakerOrder fee_rate_bps is no longer authoritative in V2 — the field is conceptually deprecated. The server appears to return "" rather than omit the field or return "0"/null, which the strict Decimal deserialize rejects.
Operational impact (real)
In a downstream live-trading project, this caused:
- ~22,000 retry-loop warnings in 16h on production from 2 stuck
order_ids that fell back to the HTTP trade-history reconcile path after WS fill-confirmation drops.
- The retry storm propagated to a single-leg unwind subsystem that drained one exchange's positions on plans where the trade-history reconcile kept failing, leaving naked directional exposure on the other exchange that had to be manually flattened.
Suggested fix
Two options, in order of preference:
(1) Lenient deserializer (non-breaking):
use serde::Deserialize;
fn deserialize_decimal_lenient<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Decimal, D::Error> {
let s = String::deserialize(d)?;
if s.is_empty() {
Ok(Decimal::ZERO)
} else {
s.parse().map_err(serde::de::Error::custom)
}
}
pub struct MakerOrder {
...
#[serde(deserialize_with = "deserialize_decimal_lenient")]
pub fee_rate_bps: Decimal,
...
}
(2) Optional field (breaking):
pub struct MakerOrder {
...
#[serde(default, deserialize_with = "deserialize_decimal_opt_lenient")]
pub fee_rate_bps: Option<Decimal>,
...
}
I'd recommend (1) since MakerOrder.fee_rate_bps is largely informational on V2 and Decimal::ZERO is a reasonable default. Happy to send a PR if helpful.
Workaround in our project
While waiting for upstream, we catch the specific error shape in our caller and treat the page as empty:
match self.client.trades(request, cursor.clone()).await {
Ok(page) => page,
Err(e) if is_v2_trades_schema_bug(&e) => break, // log once, treat as empty
Err(e) => return Err(...),
}
fn is_v2_trades_schema_bug(err: &SdkError) -> bool {
let msg = err.to_string();
msg.contains("invalid value: string \"\"") && msg.contains("Decimal")
}
This is fragile (string-matching the error message) and we lose all trades in pages that contain the bad record, so an upstream fix would let us delete the workaround.
Environment
- Crate:
polymarket_client_sdk_v2 = "0.5.1", features clob, data, ctf, tracing
- Endpoint:
https://clob.polymarket.com (V2 production, post-2026-04-28 cutover)
- Rust: 1.94.0
Summary
polymarket_client_sdk_v2 = "0.5.1"strict-deserializesMakerOrder.fee_rate_bpsasDecimal, but the V2 production CLOB server (https://clob.polymarket.com) returns this field as the empty string""for some trades. This fails the entirePage<TradeResponse>deserialization and prevents any caller from retrieving trade history when the response includes such a record.Where it fails
src/clob/types/response.rsMakerOrder.fee_rate_bps: Decimal(line ~521)Reproducer
Authenticate against
https://clob.polymarket.comand callclient.trades(TradesRequest::builder().maker_address(addr).build(), None)for any address that has trade history including this shape.Diagnostic (with
tracingfeature enabled)Why V2 likely returns
""herePer the V2 migration guide:
So per-
MakerOrderfee_rate_bpsis no longer authoritative in V2 — the field is conceptually deprecated. The server appears to return""rather than omit the field or return"0"/null, which the strictDecimaldeserialize rejects.Operational impact (real)
In a downstream live-trading project, this caused:
order_ids that fell back to the HTTP trade-history reconcile path after WS fill-confirmation drops.Suggested fix
Two options, in order of preference:
(1) Lenient deserializer (non-breaking):
(2) Optional field (breaking):
I'd recommend (1) since
MakerOrder.fee_rate_bpsis largely informational on V2 andDecimal::ZEROis a reasonable default. Happy to send a PR if helpful.Workaround in our project
While waiting for upstream, we catch the specific error shape in our caller and treat the page as empty:
This is fragile (string-matching the error message) and we lose all trades in pages that contain the bad record, so an upstream fix would let us delete the workaround.
Environment
polymarket_client_sdk_v2 = "0.5.1", featuresclob, data, ctf, tracinghttps://clob.polymarket.com(V2 production, post-2026-04-28 cutover)