Skip to content

fix(clob): type balance-allowance allowances as HashMap<Address, U256>#329

Open
moeuu wants to merge 1 commit intoPolymarket:mainfrom
moeuu:fix-balance-allowance-u256
Open

fix(clob): type balance-allowance allowances as HashMap<Address, U256>#329
moeuu wants to merge 1 commit intoPolymarket:mainfrom
moeuu:fix-balance-allowance-u256

Conversation

@moeuu
Copy link
Copy Markdown

@moeuu moeuu commented Apr 10, 2026

balance-allowance returns MAX_UINT256 allowances that overflow rust_decimal::Decimal

Summary

The CLOB /balance-allowance endpoint returns the allowances map as
HashMap<Address, String>, where each value is a decimal string representing a
uint256 on-chain allowance. When a user has fully approved the Polymarket CTF
Exchange (the normal state after the first UI trade triggers setApprovalForAll),
the value is 2^256 - 1:

{
  "balance": 58916378,
  "allowances": {
    "0x4bfb41d5b3570defd03c39a9a4d8de6bd8b8982e":
      "115792089237316195423570985008687907853269984665640564039457584007913119639937",
    "0xc5d563a36ae78145c45a50134d48a1215220f80a":
      "115792089237316195423570985008687907853269984665640564039457584007913122515535",
    "0xd91e80cf2e7be2e162c6513ced06f1dd0da35296":
      "115792089237316195423570985008687907853269984665640564039457584007913129639935"
  }
}

Any downstream consumer that tries to parse these strings as rust_decimal::Decimal
(the natural choice, matching the rest of the API) silently fails for every
entry because 2^256 - 1 ≈ 1.15e77 is far above Decimal::MAX ≈ 7.9e28.

Concretely, a helper like this silently drops every entry:

fn max_allowance_decimal(allowances: &HashMap<Address, String>) -> Result<Decimal> {
    allowances
        .values()
        .filter_map(|value| value.parse::<Decimal>().ok())  // all Err -> dropped
        .max()
        .ok_or_else(|| anyhow!("no allowance values returned"))  // always fires
}

so approval-status checks report "no allowances" even though the user has
actually fully approved all three exchange contracts. The downstream consumer
either erroneously blocks orders or has to build an ad-hoc overflow-saturating
parser as a workaround.

Proposed fix

This is a semantic issue (Decimal is the wrong type), not a bug in the SDK's
network layer. There are two reasonable paths:

Option A: type allowances as HashMap<Address, U256>

The SDK already uses alloy_primitives::U256 elsewhere (e.g. in
SignedOrder, token_id, etc.), and the API value is morphologically a
uint256 on-chain allowance, so this is the "honest" type:

// src/clob/types/response.rs
use crate::types::U256;

pub struct BalanceAllowanceResponse {
    pub balance: U256,
    pub allowances: HashMap<Address, U256>,
}

Breaking change, but the direction is clearly correct, and downstream code
that currently parses .value.parse::<U256>() can drop the parse step.

Option B: keep String and document the overflow trap prominently

Less disruptive but punts the problem to every caller. If this is chosen, I
suggest adding a module-level note on BalanceAllowanceResponse plus an
example in the docs showing the recommended U256::from_str_radix(v, 10)
parse path.

I'd lean toward A given that infinite approvals are the norm on Polymarket
post-UI-trade and every live user hits this.

Follow-on: balance field

While you're there, balance has the same flavor — it's a uint256 with 6
decimal fixed-point semantics (USDC). A user balance of $58.916378 arrives as
"58916378". Decimal handles this fine because the raw value fits, but if the
collateral ever grows to millions of USDC represented in 18-decimal fixed
point, it would also overflow. Switching to U256 uniformly would preempt this.

Reproduction

use rust_decimal::Decimal;

let raw = "115792089237316195423570985008687907853269984665640564039457584007913119639937";
assert!(raw.parse::<Decimal>().is_err()); // overflow
// But:
use alloy_primitives::U256;
use std::str::FromStr;
assert!(U256::from_str(raw).is_ok());

Live example captured during authenticated balance_allowance(Collateral) on
an MetaMask-logged-in account that has traded through the Polymarket UI.

Notes

  • Observed with polymarket-client-sdk v0.4.4.
  • Independent of the DecimalFromAny deserializer fix in a separate PR.
  • No feature flag changes required.

Note

Medium Risk
This is a breaking API-type change (allowances values move from String to U256) that can impact downstream consumers and serialization assumptions. Runtime risk is otherwise low since it primarily affects deserialization of the /balance-allowance response.

Overview
Updates BalanceAllowanceResponse.allowances to HashMap<Address, U256> (from String) to correctly represent on-chain uint256 allowances and avoid overflow/consumer re-parsing when the backend returns 2^256 - 1.

Adjusts CLOB integration tests accordingly and adds a regression test ensuring /balance-allowance accepts and deserializes MAX_UINT256 allowances to U256::MAX.

Reviewed by Cursor Bugbot for commit 0140d9a. Bugbot is set up for automated code reviews on this repo. Configure here.

The `/balance-allowance` endpoint returns the `allowances` map as
`{address: "<uint256 decimal string>"}`. For MetaMask-logged-in
Polymarket accounts the typical "fully approved" state — reached the
first time the UI triggers `setApprovalForAll` plus USDC `approve` on
the exchange contracts — returns `2^256 - 1` as each value:

    "allowances": {
        "0x4bfb41d5b3570defd03c39a9a4d8de6bd8b8982e":
            "115792089237316195423570985008687907853269984665640564039457584007913119639937",
        ...
    }

The previous type was `HashMap<Address, String>`, which forced every
consumer to re-parse the values. The obvious parse path (mirroring the
`balance: Decimal` field directly above it) is
`rust_decimal::Decimal::from_str(v)`, which *silently drops* every
fully-approved entry because `2^256 - 1 ≈ 1.15e77` is far above
`Decimal::MAX ≈ 7.9e28`. A downstream allowance-sum helper that
filtered with `filter_map(|v| v.parse::<Decimal>().ok())` would then
report "no allowances" for users who are, in fact, fully approved,
blocking order submission or making approvals-check lie.

Changing the field type to `HashMap<Address, U256>` — which is the
honest on-chain type — fixes this class of bug at the SDK boundary.
`U256` already has `serde::Deserialize` support via alloy, so the
JSON-string-to-U256 parse happens for free during deserialization,
and consumers receive a correctly-typed value that can safely hold
MAX_UINT256. This is a breaking API change: downstream code that
accessed `.allowances: HashMap<Address, String>` directly will need
to update the type.

Regression test `balance_allowance_accepts_max_uint256_allowances`
feeds a real MAX_UINT256 literal through an httpmock server and
asserts the deserialized value equals `U256::MAX`, which locks in the
expected behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 10, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.54%. Comparing base (77264a4) to head (0140d9a).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #329   +/-   ##
=======================================
  Coverage   85.54%   85.54%           
=======================================
  Files          32       32           
  Lines        5167     5167           
=======================================
  Hits         4420     4420           
  Misses        747      747           
Flag Coverage Δ
rust 85.54% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant