Skip to content

fix(signing): serialize non-string HMAC bodies with json.dumps (#108) - #109

Open
erik-polymarket wants to merge 1 commit into
mainfrom
fix/hmac-json-serialization
Open

fix(signing): serialize non-string HMAC bodies with json.dumps (#108)#109
erik-polymarket wants to merge 1 commit into
mainfrom
fix/hmac-json-serialization

Conversation

@erik-polymarket

@erik-polymarket erik-polymarket commented Aug 5, 2026

Copy link
Copy Markdown

Problem

build_hmac_signature serialized non-string bodies with str(body).replace("'", '"'). That matches JSON for string-only dicts, but Python False/None render as False/None instead of JSON false/null. Signing {"deferExec": False} produces an HMAC over {"deferExec": False} while the payload actually sent to the server is {"deferExec": false} — so the signature is invalid.

Internal callers avoid this by passing serialized_body (compact json.dumps), which create_level_2_headers prefers. But build_hmac_signature is exported and the header builder falls back to the dict body when serialized_body is omitted, so external/manual callers on the dict path get a broken signature.

Fixes #108.

Fix

py_clob_client_v2/signing/hmac.py:

  • Non-string bodies → json.dumps(body, ensure_ascii=False), so False/None become false/null.
  • Pre-serialized string bodies are signed verbatim (removing the obsolete .replace("'", '"') hack, which only existed to convert Python dict repr to JSON and could corrupt strings containing apostrophes).

Backward compatibility: for string-only dicts, json.dumps with default separators produces the same bytes as the old str(dict).replace(...) form (e.g. {"hash": "0x123"}), so previously valid signatures are unchanged. The existing pinned baseline signature test still passes.

Tests

Added regression tests in tests/signing/test_hmac.py covering boolean, None, and nested bool/null dict values — each asserts the signature equals the json.dumps form and the explicit JSON literal (e.g. {"deferExec": false}), and does not equal the old broken str()-based rendering.

Full suite: 230 passed (22 in test_hmac.py). hmac.py passes black --check.


Note

Medium Risk
Changes request-signing payload canonicalization; wrong serialization would break authenticated API calls, though string-only dicts stay compatible and tests cover the regression.

Overview
Fixes invalid API HMAC signatures when callers pass a dict body containing False, None, or other non-JSON-literal Python values.

build_hmac_signature no longer builds the signed message from str(body).replace("'", '"'). String bodies are appended verbatim; structured bodies use json.dumps(..., ensure_ascii=False) so the signed bytes match JSON on the wire (false/null, not False/None). That aligns signing with the dict fallback path in create_level_2_headers when serialized_body is omitted.

Regression tests assert dict signing matches explicit JSON literals and no longer matches the old str()-based payload.

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

build_hmac_signature rendered non-string bodies with
str(body).replace("'", '"'), which emits Python False/None instead of
JSON false/null. Signing a dict like {"deferExec": False} therefore
produced an HMAC over `{"deferExec": False}` while the server receives
`{"deferExec": false}`, yielding an invalid signature for external
callers that rely on the dict path (internal callers dodge this via
serialized_body).

Non-string bodies are now serialized with json.dumps(body,
ensure_ascii=False); pre-serialized string bodies are signed verbatim.
The canonical dict form is unchanged for string-only bodies (default
separators still match the existing spaced baseline), so previously
valid signatures are preserved.

Adds regression tests covering boolean, None, and nested bool/null
dictionary values.

Fixes #108
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.

Dict bodies with booleans or None produce a different HMAC than JSON

1 participant