Skip to content

MakerOrder.fee_rate_bps deserialization fails on V2 production: server returns "" (empty string) for protocol-controlled fees #34

Description

@DavidSt49

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions