From 0140d9a62a17bee18f1e8b1f1a707308b9766421 Mon Sep 17 00:00:00 2001 From: moeuu Date: Sat, 11 Apr 2026 07:07:09 +0900 Subject: [PATCH] fix(clob): type balance-allowance allowances as HashMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/balance-allowance` endpoint returns the `allowances` map as `{address: ""}`. 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`, 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::().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` — 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` 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) --- src/clob/types/response.rs | 6 +++++- tests/clob.rs | 42 +++++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/clob/types/response.rs b/src/clob/types/response.rs index 2dbd1785..82a9966e 100644 --- a/src/clob/types/response.rs +++ b/src/clob/types/response.rs @@ -444,9 +444,13 @@ pub struct NotificationPayload { #[derive(Debug, Default, Clone, Deserialize, Builder, PartialEq)] pub struct BalanceAllowanceResponse { pub balance: Decimal, + /// Per-spender on-chain allowances, keyed by the ERC-20 spender + /// (exchange contract) address. Values are `uint256` because the + /// typical "fully approved" state on Polymarket returns + /// `2^256 - 1`, which overflows any fixed-point decimal type. #[serde(default)] #[builder(default)] - pub allowances: HashMap, + pub allowances: HashMap, } #[non_exhaustive] diff --git a/tests/clob.rs b/tests/clob.rs index f4bacadb..a1d394b2 100644 --- a/tests/clob.rs +++ b/tests/clob.rs @@ -2261,7 +2261,7 @@ mod authenticated { let expected = BalanceAllowanceResponse::builder() .balance(Decimal::ZERO) - .allowances(HashMap::from_iter([(Address::ZERO, "1".to_owned())])) + .allowances(HashMap::from_iter([(Address::ZERO, U256::from(1_u64))])) .build(); assert_eq!(response, expected); @@ -2270,6 +2270,46 @@ mod authenticated { Ok(()) } + #[tokio::test] + async fn balance_allowance_accepts_max_uint256_allowances() -> anyhow::Result<()> { + // Regression: MetaMask-logged-in Polymarket accounts see allowances + // equal to 2^256 - 1 after the first UI trade triggers + // `setApprovalForAll` + USDC `approve` on the exchange contracts. + // The previous `HashMap` type silently forced + // downstream consumers to re-parse these, and any caller that + // naively used `rust_decimal::Decimal::from_str` on the strings + // would drop the entries because `2^256 - 1` overflows Decimal. + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let max_uint256 = + "115792089237316195423570985008687907853269984665640564039457584007913129639935"; + let spender = Address::from([0x42; 20]); + + let mock = server.mock(|when, then| { + when.method(GET).path("/balance-allowance"); + then.status(StatusCode::OK).json_body(json!({ + "balance": 58_916_378, + "allowances": { spender.to_string(): max_uint256 } + })); + }); + + let request = BalanceAllowanceRequest::builder() + .asset_type(AssetType::Collateral) + .build(); + let response = client.balance_allowance(request).await?; + + assert_eq!(response.balance, dec!(58_916_378)); + assert_eq!( + response.allowances.get(&spender).copied(), + Some(U256::from_str(max_uint256)?) + ); + assert_eq!(response.allowances.get(&spender).copied(), Some(U256::MAX)); + mock.assert(); + + Ok(()) + } + #[tokio::test] async fn update_balance_allowance_should_succeed() -> anyhow::Result<()> { let server = MockServer::start();