Skip to content

rtds: subscribe_raw serializes filters as a JSON object for non-chainlink topics; RTDS rejects with "Invalid request body" (TWAP topics silently unusable) #108

Description

@CHANGGELY

Summary

SubscriptionManager::subscribe / subscribe_raw silently fail for every RTDS topic whose
filters are not special-cased by the SDK's manual Serialize impl. For non-chainlink topics
(e.g. crypto_prices_twap_sixty), filters is serialized as a JSON object instead of a
compact JSON string. The RTDS service rejects the whole subscribe frame with
{"message": "Invalid request body"}, and that reply is dropped by the SDK's message parser,
so callers see a "healthy" connection with zero updates and no error.

This makes crypto_prices_twap_sixty / crypto_prices_twap_thirty unusable through the 0.7.0
SDK and — because nothing surfaces the rejection — very hard to diagnose.

Environment

  • polymarket_client_sdk_v2 0.7.0 (crates.io), subscribe_raw path
  • Endpoint: wss://ws-live-data.polymarket.com
  • Observed against the production RTDS service

Root cause

src/rtds/types/request.rs, impl Serialize for Subscription:

if self.topic == "crypto_prices_chainlink" {
    // Chainlink: emit filters as string, e.g. "{\"symbol\":\"btc/usd\"}"
    map.serialize_entry("filters", filters)?;
} else if let Ok(json_value) = serde_json::from_str::<Value>(filters) {
    // Other topics: parse and emit as raw JSON, e.g. ["btcusdt","ethusdt"]
    map.serialize_entry("filters", &json_value)?;
}

{"symbol":"btc/usd"} is valid JSON, so for crypto_prices_twap_sixty it is emitted as a raw
object:

{"action":"subscribe","subscriptions":[{"topic":"crypto_prices_twap_sixty","type":"update","filters":{"symbol":"btc/usd"}}]}

The RTDS docs require the compact JSON string form
(filters must use the exact compact JSON form shown above, with one lowercase symbol and no spaces, such as {"symbol":"btc/usd"}), i.e.:

{"action":"subscribe","subscriptions":[{"topic":"crypto_prices_twap_sixty","type":"update","filters":"{\"symbol\":\"btc/usd\"}"}]}

Minimal reproduction (no SDK, Node 22+ has a global WebSocket)

const cases = [
  { name: "filters as compact JSON string", filters: JSON.stringify({ symbol: "btc/usd" }) },
  { name: "filters as JSON object (what 0.7.0 emits)", filters: { symbol: "btc/usd" } },
];
for (const c of cases) {
  const ws = new WebSocket("wss://ws-live-data.polymarket.com");
  ws.onopen = () => ws.send(JSON.stringify({
    action: "subscribe",
    subscriptions: [{ topic: "crypto_prices_twap_sixty", type: "update", filters: c.filters }],
  }));
  let n = 0;
  ws.onmessage = (ev) => { n++; if (n === 1) console.log(c.name, "first frame:", String(ev.data).slice(0, 120)); };
  setTimeout(() => { console.log(c.name, "frames in 15s:", n); ws.close(); }, 15000);
}

Observed output against production RTDS:

filters as compact JSON string first frame: {"connection_id":"…","payload":{"full_accuracy_value":"…","symbol":"btc/usd","timestamp":…,"value":"79765.74…","window_s":60},…}
filters as compact JSON string frames in 15s: 14
filters as JSON object (what 0.7.0 emits) first frame: {"message": "Invalid request body", "connectionId":"…", "requestId":"…"}
filters as JSON object (what 0.7.0 emits) frames in 15s: 1

As a control, crypto_prices_chainlink (string form via the SDK special case) streams normally.

Impact

  • crypto_prices_twap_sixty / crypto_prices_twap_thirty cannot be consumed at all through
    subscribe_raw in 0.7.0, and the failure is silent: the socket stays ESTABLISHED, the
    application-level PING/PONG heartbeat keeps working, and no error ever reaches the caller.
  • Anyone wiring the new TWAP resolution source (see feat(rtds): chainlink twap price streams #105) will hit this.

Suggested fix

  • Serialize filters as the compact JSON string for all RTDS topics (the object/array form
    only makes sense for crypto_prices, which expects a JSON array — that one can keep the
    special case), or gate the object form on topic == "crypto_prices" only.
  • Consider surfacing server-level Invalid request body replies to subscribers (or at least
    logging them) so subscription rejections are not fully silent.

Related: #90 (SubscriptionManager refcount identity ignores filters — same area of the
subscription path, different failure mode).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions