Skip to content

Commit 69eaddb

Browse files
vvillait88claude
andauthored
fix(x402): coerce dict payload + serialize Pydantic SettleResponse (1.3.3) (#10)
## Summary Two sibling bugs in `process_x402_settle`'s verify → settle path, both surfaced once #9 (1.3.2) cleared the facilitator-wiring layer. ### 1) Dict `payload` not coerced to typed `PaymentPayload` `verify_x402_request` returns `payload` as a plain dict (the result of `json.loads(base64.b64decode(X-Payment))`), but x402 2.9's `verify_payment` / `settle_payment` call `payload.get_scheme()` and other Pydantic-model methods on it. Without coercion, prod store crashed at the verify leg with: ``` AttributeError("'dict' object has no attribute 'get_scheme'") ``` New `_coerce_payment_payload()` routes by `x402Version` field — `1` → `PaymentPayloadV1` (flat top-level shape); anything else → `PaymentPayload` (v2 nested under `accepted`). Falls back to the original input on any failure so caller-typed instances pass through unchanged. Both verify **and** settle legs now receive the coerced payload (so the post-verify settle leg also sees a typed model). ### 2) Pydantic `SettleResponse` not JSON-serializable for the response header x402 2.9's `settle_payment` returns a `SettleResponse` Pydantic model. The helper was doing plain `json.dumps(settle_result)` to base64-encode the X-Payment-Response header — that raises `TypeError("Object of type SettleResponse is not JSON serializable")`. Caught by the surrounding `except Exception`, the helper would mark the settle as `settle_failed` **after the on-chain settle had already succeeded** — payment taken, order shows as failed. New `_settle_result_to_json_bytes()` uses `model_dump_json(by_alias=True)` for Pydantic models (so emitted keys match the wire shape — `errorReason` / `errorMessage` camelCase) and falls through to `json.dumps` for plain dicts (older x402 / test stubs). ## Verification Live integration test against the real Coinbase CDP facilitator (with prod CDP creds, mainnet) confirmed: 1. Dict → typed `PaymentPayload` happens (`get_scheme()` resolves on the model). 2. `verify_payment` is reached with the typed model. 3. CDP receives the request, validates through schema, returns a structured 400 (rejected the synth signature) — **no `AttributeError` anywhere**. The CDP rejection at the schema layer (not the type layer) is the strongest signal short of an actual settle. Real `agentscore-pay`-signed payloads conform to the x402V2 schema and will pass. ## Tests - New: `coerces_dict_payload_v2_to_typed_payment_payload` — asserts both verify + settle legs see the typed `PaymentPayload` and `get_scheme()` resolves. - New: `passes_typed_payment_payload_unchanged` — caller-typed instance flows through. - New: `serializes_pydantic_settle_result_to_payment_response_header` — Pydantic `SettleResponse` round-trips to base64'd JSON with `errorReason` (camelCase wire key). - New: `serializes_dict_settle_result_for_legacy_stubs` — plain-dict settle results still serialize. - Full suite: **723 passed, 3 skipped, 95.30% coverage**. ## Test plan - [ ] CI green. - [ ] Tag `v1.3.3`, push tag → trigger PyPI publish. - [ ] Bump `core/store/uv.lock` to 1.3.3 + fix store's `tx_hash` extraction (companion bug — store reads `settle_obj.get("transaction")` only when isinstance dict; with 1.3.3 the inner `settle_result` will be a Pydantic model, so the dict branch is skipped and tx_hash silently stays `None`). - [ ] Deploy store prod. - [ ] T4 base mainnet smoke against agents.agentscore.sh — expect `200 + order` with non-null `tx_hash`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dcb8041 commit 69eaddb

4 files changed

Lines changed: 265 additions & 6 deletions

File tree

agentscore_commerce/payment/x402_settle.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,40 @@ def _coerce_resource_config(config: Any) -> Any:
220220
return config
221221

222222

223+
def _coerce_payment_payload(payload: Any) -> Any:
224+
"""Best-effort dict → x402 ``PaymentPayload`` (v1 or v2) coercion.
225+
226+
``verify_x402_request`` returns ``payload`` as a plain dict (the result of
227+
``json.loads(base64.b64decode(X-Payment))``), but x402 2.9's
228+
``server.verify_payment`` / ``server.settle_payment`` call ``payload.get_scheme()``
229+
and other typed-model methods on it. Without coercion, the dict raises
230+
``AttributeError("'dict' object has no attribute 'get_scheme'")`` on the verify leg.
231+
232+
Routes by the ``x402Version`` field: ``1`` → ``PaymentPayloadV1`` (flat shape with
233+
top-level ``scheme`` / ``network``); anything else → ``PaymentPayload`` (v2 shape
234+
nested under ``accepted``). Falls back to the original dict on any failure so callers
235+
that already pass typed instances or unusual shapes still flow through unchanged.
236+
"""
237+
if not isinstance(payload, dict):
238+
return payload
239+
try:
240+
from x402.schemas import PaymentPayload
241+
from x402.schemas.v1 import PaymentPayloadV1
242+
except ImportError:
243+
return payload
244+
version = payload.get("x402Version")
245+
model = PaymentPayloadV1 if version == 1 else PaymentPayload
246+
try:
247+
return model.model_validate(payload)
248+
except Exception:
249+
return payload
250+
251+
223252
async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402SettleResult:
224253
"""Run the x402 verify→settle flow and return a tagged outcome."""
225254
server = input.x402_server
226255
resource_config = _coerce_resource_config(input.resource_config)
256+
payload = _coerce_payment_payload(input.payload)
227257

228258
try:
229259
built_requirements = server.build_payment_requirements(resource_config)
@@ -267,7 +297,7 @@ async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402Settl
267297
# — not ``process_payment_request`` (a fictional method that earlier versions of this
268298
# helper called and only ever worked against test stubs).
269299
try:
270-
verify_result = await server.verify_payment(input.payload, matched_requirement)
300+
verify_result = await server.verify_payment(payload, matched_requirement)
271301
except Exception as err:
272302
return ProcessX402SettleFailure(phase="facilitator_error", step="verify_payment", error=err)
273303

@@ -288,11 +318,10 @@ async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402Settl
288318
return ProcessX402SettleFailure(phase="verify_failed", verify_result=verify_result)
289319

290320
try:
291-
settle_result = await server.settle_payment(input.payload, matched_requirement)
321+
settle_result = await server.settle_payment(payload, matched_requirement)
292322
payment_response_header: str | None = None
293323
if settle_result is not None:
294-
payload_bytes = json.dumps(settle_result, separators=(",", ":")).encode()
295-
payment_response_header = base64.b64encode(payload_bytes).decode()
324+
payment_response_header = base64.b64encode(_settle_result_to_json_bytes(settle_result)).decode()
296325
return ProcessX402SettleSuccess(
297326
matched_requirement=matched_requirement,
298327
settle_result=settle_result,
@@ -301,3 +330,19 @@ async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402Settl
301330
)
302331
except Exception as err:
303332
return ProcessX402SettleFailure(phase="settle_failed", error=err, matched_requirement=matched_requirement)
333+
334+
335+
def _settle_result_to_json_bytes(settle_result: Any) -> bytes:
336+
"""Serialize the settle result to a base64-friendly JSON byte string.
337+
338+
x402 2.9's ``settle_payment`` returns a Pydantic ``SettleResponse`` model that
339+
``json.dumps`` rejects with ``TypeError: Object of type SettleResponse is not
340+
JSON serializable``. Use ``model_dump_json(by_alias=True)`` for Pydantic models
341+
(so emitted keys match the wire shape — ``errorReason`` / ``errorMessage`` rather
342+
than the snake_case attrs) and fall through to ``json.dumps`` for plain dicts
343+
(used by older x402 / test stubs).
344+
"""
345+
model_dump_json = getattr(settle_result, "model_dump_json", None)
346+
if callable(model_dump_json):
347+
return model_dump_json(by_alias=True).encode()
348+
return json.dumps(settle_result, separators=(",", ":")).encode()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "agentscore-commerce"
7-
version = "1.3.2"
7+
version = "1.3.3"
88
description = "Agent commerce SDK for Python — identity middleware (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce."
99
readme = "README.md"
1010
license = "MIT"

tests/test_lifted_helpers.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,220 @@ async def settle_payment(self, _payload: object, _req: object) -> dict:
534534
assert captured["cfg"] is typed
535535

536536

537+
@pytest.mark.asyncio
538+
async def test_process_x402_settle_coerces_dict_payload_v2_to_typed_payment_payload():
539+
"""Same fix as resource_config: ``verify_x402_request`` returns ``payload`` as a
540+
plain dict (the result of ``json.loads(base64.b64decode(X-Payment))``); x402 2.9's
541+
``server.verify_payment`` calls ``payload.get_scheme()`` and other typed-model methods
542+
on it. Coerce dicts → ``PaymentPayload`` (or ``PaymentPayloadV1`` when ``x402Version=1``)
543+
so the helper doesn't ``AttributeError`` at the verify leg.
544+
"""
545+
captured: dict = {}
546+
547+
class _CapturingServer:
548+
def build_payment_requirements(self, _cfg: object, _ext: object = None) -> list:
549+
return [{"id": "req1"}]
550+
551+
async def verify_payment(self, payload: object, _req: object) -> dict:
552+
captured["verify_payload"] = payload
553+
return {"is_valid": True}
554+
555+
async def settle_payment(self, payload: object, _req: object) -> dict:
556+
captured["settle_payload"] = payload
557+
return {"tx_hash": "0xabc"}
558+
559+
payload_dict = {
560+
"x402Version": 2,
561+
"accepted": {
562+
"scheme": "exact",
563+
"network": "eip155:8453",
564+
"amount": "100000",
565+
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
566+
"payTo": "0x000000000000000000000000000000000000dEaD",
567+
"maxTimeoutSeconds": 300,
568+
},
569+
"payload": {
570+
"authorization": {
571+
"from": "0xeb2Ca790F72787c7e61bC6c861353a1e4ACDFCa5",
572+
"to": "0x000000000000000000000000000000000000dEaD",
573+
"value": "100000",
574+
"validAfter": 0,
575+
"validBefore": 9999999999,
576+
"nonce": "0x" + "aa" * 32,
577+
},
578+
"signature": "0x" + "cc" * 65,
579+
},
580+
}
581+
res = await process_x402_settle(
582+
ProcessX402SettleInput(
583+
x402_server=_CapturingServer(),
584+
payload=payload_dict,
585+
resource_config={
586+
"scheme": "exact",
587+
"network": "eip155:8453",
588+
"price": "$0.10",
589+
"payTo": "0x" + "00" * 19 + "dE" + "aD",
590+
"maxTimeoutSeconds": 300,
591+
},
592+
resource_meta=_RESOURCE_META,
593+
)
594+
)
595+
assert isinstance(res, ProcessX402SettleSuccess)
596+
# Both verify and settle legs received the typed Pydantic model, not the raw dict.
597+
verify_payload = captured["verify_payload"]
598+
settle_payload = captured["settle_payload"]
599+
assert type(verify_payload).__name__ == "PaymentPayload"
600+
assert type(settle_payload).__name__ == "PaymentPayload"
601+
# The typed model exposes the get_scheme() method that x402's facilitator calls.
602+
assert hasattr(verify_payload, "get_scheme")
603+
assert verify_payload.get_scheme() == "exact"
604+
605+
606+
@pytest.mark.asyncio
607+
async def test_process_x402_settle_serializes_pydantic_settle_result_to_payment_response_header():
608+
"""x402 2.9's ``settle_payment`` returns a Pydantic ``SettleResponse`` model;
609+
plain ``json.dumps`` rejects it with ``TypeError``. The helper must call
610+
``model_dump_json(by_alias=True)`` so the X-Payment-Response header stays
611+
base64'd JSON with the right wire keys (``errorReason`` not ``error_reason``).
612+
"""
613+
from x402.schemas import SettleResponse
614+
615+
pydantic_settle = SettleResponse(
616+
success=True,
617+
transaction="0xabc",
618+
network="eip155:8453",
619+
payer="0x000000000000000000000000000000000000dEaD",
620+
amount=None,
621+
)
622+
623+
class _PydanticSettleServer:
624+
def build_payment_requirements(self, _cfg: object, _ext: object = None) -> list:
625+
return [{"id": "req1"}]
626+
627+
async def verify_payment(self, _payload: object, _req: object) -> dict:
628+
return {"is_valid": True}
629+
630+
async def settle_payment(self, _payload: object, _req: object) -> SettleResponse:
631+
return pydantic_settle
632+
633+
res = await process_x402_settle(
634+
ProcessX402SettleInput(
635+
x402_server=_PydanticSettleServer(),
636+
payload={},
637+
resource_config={
638+
"scheme": "exact",
639+
"network": "eip155:8453",
640+
"price": "$0.10",
641+
"payTo": "0x" + "00" * 19 + "dE" + "aD",
642+
"maxTimeoutSeconds": 300,
643+
},
644+
resource_meta=_RESOURCE_META,
645+
)
646+
)
647+
assert isinstance(res, ProcessX402SettleSuccess)
648+
assert res.payment_response_header is not None
649+
decoded = json.loads(base64.b64decode(res.payment_response_header).decode())
650+
assert decoded["success"] is True
651+
assert decoded["transaction"] == "0xabc"
652+
assert decoded["network"] == "eip155:8453"
653+
# by_alias=True: wire shape uses errorReason / errorMessage (camelCase) — not snake_case.
654+
assert "errorReason" in decoded
655+
656+
657+
@pytest.mark.asyncio
658+
async def test_process_x402_settle_serializes_dict_settle_result_for_legacy_stubs():
659+
"""Plain-dict settle results (from older x402 / test stubs) still serialize."""
660+
661+
class _DictSettleServer:
662+
def build_payment_requirements(self, _cfg: object, _ext: object = None) -> list:
663+
return [{"id": "req1"}]
664+
665+
async def verify_payment(self, _payload: object, _req: object) -> dict:
666+
return {"is_valid": True}
667+
668+
async def settle_payment(self, _payload: object, _req: object) -> dict:
669+
return {"success": True, "transaction": "0xdef"}
670+
671+
res = await process_x402_settle(
672+
ProcessX402SettleInput(
673+
x402_server=_DictSettleServer(),
674+
payload={},
675+
resource_config={
676+
"scheme": "exact",
677+
"network": "eip155:8453",
678+
"price": "$0.10",
679+
"payTo": "0x" + "00" * 19 + "dE" + "aD",
680+
"maxTimeoutSeconds": 300,
681+
},
682+
resource_meta=_RESOURCE_META,
683+
)
684+
)
685+
assert isinstance(res, ProcessX402SettleSuccess)
686+
assert res.payment_response_header is not None
687+
decoded = json.loads(base64.b64decode(res.payment_response_header).decode())
688+
assert decoded == {"success": True, "transaction": "0xdef"}
689+
690+
691+
@pytest.mark.asyncio
692+
async def test_process_x402_settle_passes_typed_payment_payload_unchanged():
693+
"""If the caller already provides a typed PaymentPayload, pass it through unchanged."""
694+
from x402.schemas import PaymentPayload
695+
696+
typed = PaymentPayload.model_validate(
697+
{
698+
"x402Version": 2,
699+
"accepted": {
700+
"scheme": "exact",
701+
"network": "eip155:8453",
702+
"amount": "100000",
703+
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
704+
"payTo": "0x000000000000000000000000000000000000dEaD",
705+
"maxTimeoutSeconds": 300,
706+
},
707+
"payload": {
708+
"authorization": {
709+
"from": "0xeb2Ca790F72787c7e61bC6c861353a1e4ACDFCa5",
710+
"to": "0x000000000000000000000000000000000000dEaD",
711+
"value": "100000",
712+
"validAfter": 0,
713+
"validBefore": 9999999999,
714+
"nonce": "0x" + "aa" * 32,
715+
},
716+
"signature": "0x" + "cc" * 65,
717+
},
718+
}
719+
)
720+
captured: dict = {}
721+
722+
class _CapturingServer:
723+
def build_payment_requirements(self, _cfg: object, _ext: object = None) -> list:
724+
return [{"id": "req1"}]
725+
726+
async def verify_payment(self, payload: object, _req: object) -> dict:
727+
captured["payload"] = payload
728+
return {"is_valid": True}
729+
730+
async def settle_payment(self, _payload: object, _req: object) -> dict:
731+
return {"tx_hash": "0xabc"}
732+
733+
res = await process_x402_settle(
734+
ProcessX402SettleInput(
735+
x402_server=_CapturingServer(),
736+
payload=typed,
737+
resource_config={
738+
"scheme": "exact",
739+
"network": "eip155:8453",
740+
"price": "$0.10",
741+
"payTo": "0x" + "00" * 19 + "dE" + "aD",
742+
"maxTimeoutSeconds": 300,
743+
},
744+
resource_meta=_RESOURCE_META,
745+
)
746+
)
747+
assert isinstance(res, ProcessX402SettleSuccess)
748+
assert captured["payload"] is typed
749+
750+
537751
# ─────────────────────────────────────────────────────────────────────────────
538752
# process_x402_settle: facilitator_error wrap
539753
# ─────────────────────────────────────────────────────────────────────────────

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)