Skip to content

Commit eef3dfe

Browse files
vvillait88claude
andauthored
fix(x402): strip extensions/resource from paymentPayload before facilitator (parity with node 2.7.5) (#92)
## Parity with node-commerce 2.7.5 x402 clients echo the 402 challenge's `extensions` (Bazaar input schema) + `resource` into the payload alongside the signed `payload` + `accepted`. The Coinbase facilitator's `/x402/verify` validates the payment payload against its `x402V2PaymentPayload` schema, which is `{ x402Version, payload, accepted }` and admits neither `extensions` nor `resource`: their presence makes the payload match no union branch (`must match one of [x402V2PaymentPayload, x402V1PaymentPayload]`) and settle fails. Routes with a large echoed Bazaar schema fail while small ones slip through. ## Fix `process_x402_settle` now runs `strip_unsigned_x402_payload_fields` before coercing to the typed model whose `model_dump(by_alias, exclude_none)` reaches the facilitator, dropping only `extensions` + `resource` and keeping `accepted` (which CDP requires, and which `verify_x402_request` reads for network/payTo). Neither stripped block is part of the EIP-3009 signature. Confirmed against the live CDP facilitator: the stripped accepted-nested payload passes schema and reaches signature verify; a flat `{ x402Version, scheme, network, payload }` is rejected with `requires 'accepted'`. ## Tests Unit: drop-both / keep-accepted, unchanged-when-absent (same object), single-field strip, non-dict passthrough. Integration: `process_x402_settle` forwards a payload to BOTH verify and settle with `accepted` kept and `extensions`/`resource` gone. Full suite green (1840 passed, 95.36% coverage), ruff + ty clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8313db2 commit eef3dfe

5 files changed

Lines changed: 85 additions & 3 deletions

File tree

agentscore_commerce/payment/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
coerce_resource_config,
7777
process_x402_settle,
7878
settle_result_to_json_bytes,
79+
strip_unsigned_x402_payload_fields,
7980
)
8081
from agentscore_commerce.payment.x402_validation import (
8182
X402_SUPPORTED_BASE_NETWORKS,
@@ -164,6 +165,7 @@
164165
"resolve_recipient",
165166
"settle_result_to_json_bytes",
166167
"settlement_override_header",
168+
"strip_unsigned_x402_payload_fields",
167169
"usd_to_atomic",
168170
"validate_x402_network_config",
169171
"verify_x402_request",

agentscore_commerce/payment/x402_settle.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,31 @@ def coerce_payment_payload(payload: Any) -> Any:
301301
return payload
302302

303303

304+
def strip_unsigned_x402_payload_fields(payload: Any) -> Any:
305+
"""Drop the non-signed ``extensions`` + ``resource`` blocks from a decoded X-Payment payload.
306+
307+
x402 clients echo the 402 challenge's ``extensions`` (Bazaar input schema) and ``resource``
308+
into the payload alongside the signed ``payload`` + ``accepted``. Neither is part of the
309+
EIP-3009 signature (which covers only ``payload.authorization``). The Coinbase facilitator's
310+
``/x402/verify`` validates the payment payload against its ``x402V2PaymentPayload`` schema,
311+
which is ``{x402Version, payload, accepted}`` and admits neither ``extensions`` nor
312+
``resource``: their presence makes the payload match no union branch and CDP rejects it
313+
(``must match one of [x402V2PaymentPayload, x402V1PaymentPayload]``). Routes whose echoed
314+
Bazaar schema is large fail while small ones slip through, so it presents as route-dependent
315+
but is one shape bug.
316+
317+
``accepted`` (which ``verify_x402_request`` reads for network/payTo, and which CDP requires)
318+
and every other field stay intact; only the two echoed challenge blocks are dropped, before
319+
the payload is coerced to the typed model whose ``model_dump`` reaches the facilitator.
320+
Non-dict payloads, and payloads carrying neither field, pass through unchanged.
321+
"""
322+
if not isinstance(payload, dict):
323+
return payload
324+
if "extensions" not in payload and "resource" not in payload:
325+
return payload
326+
return {k: v for k, v in payload.items() if k not in ("extensions", "resource")}
327+
328+
304329
async def process_x402_settle(
305330
*,
306331
x402_server: Any,
@@ -322,7 +347,9 @@ async def process_x402_settle(
322347
"""
323348
server = x402_server
324349
coerced_config = coerce_resource_config(resource_config)
325-
coerced_payload = coerce_payment_payload(payload)
350+
# Drop the echoed extensions/resource blocks before coercion: CDP's verify schema admits
351+
# neither, so their presence makes the payload match no union branch and settle fails.
352+
coerced_payload = coerce_payment_payload(strip_unsigned_x402_payload_fields(payload))
326353

327354
try:
328355
built_requirements = server.build_payment_requirements(coerced_config)

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 = "2.5.9"
7+
version = "2.5.10"
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_x402_settle_extra.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
classify_x402_settle_result,
1212
process_x402_settle,
1313
settle_result_to_json_bytes,
14+
strip_unsigned_x402_payload_fields,
1415
)
1516

1617

@@ -123,3 +124,55 @@ def test_settle_result_to_json_bytes() -> None:
123124
out = settle_result_to_json_bytes({"a": 1, "b": "two"})
124125
assert isinstance(out, bytes)
125126
assert b'"a"' in out
127+
128+
129+
_ACCEPTED = {"scheme": "exact", "network": "eip155:8453", "payTo": "0xabc"}
130+
_INNER = {"authorization": {"from": "0xsigner", "to": "0xabc"}, "signature": "0xdeadbeef"}
131+
_WIRE = {
132+
"x402Version": 2,
133+
"payload": _INNER,
134+
"accepted": _ACCEPTED,
135+
"extensions": {"bazaar": {"schema": {"type": "object", "properties": {"phone": {"type": "string"}}}}},
136+
"resource": {"url": "https://x/person/base/no-pii", "description": "d" * 600, "tags": ["person"]},
137+
}
138+
139+
140+
def test_strip_drops_extensions_and_resource_keeps_accepted() -> None:
141+
assert strip_unsigned_x402_payload_fields(_WIRE) == {
142+
"x402Version": 2,
143+
"payload": _INNER,
144+
"accepted": _ACCEPTED,
145+
}
146+
147+
148+
def test_strip_returns_same_object_when_neither_field_present() -> None:
149+
lean = {"x402Version": 2, "payload": _INNER, "accepted": _ACCEPTED}
150+
assert strip_unsigned_x402_payload_fields(lean) is lean
151+
152+
153+
def test_strip_handles_single_field_and_non_dict() -> None:
154+
assert strip_unsigned_x402_payload_fields({"payload": _INNER, "resource": {"url": "x"}}) == {"payload": _INNER}
155+
assert strip_unsigned_x402_payload_fields({"payload": _INNER, "extensions": {"bazaar": {}}}) == {"payload": _INNER}
156+
assert strip_unsigned_x402_payload_fields(None) is None
157+
assert strip_unsigned_x402_payload_fields("not-a-dict") == "not-a-dict"
158+
159+
160+
@pytest.mark.asyncio
161+
async def test_process_x402_settle_strips_bloat_before_facilitator() -> None:
162+
# Incomplete `accepted` makes coerce_payment_payload leave the payload a plain dict, so we
163+
# can assert directly on what reaches verify_payment / settle_payment. The strip must have
164+
# removed extensions/resource and kept accepted (which CDP's v2 schema requires).
165+
server = _make_server()
166+
result = await process_x402_settle(
167+
x402_server=server,
168+
payload=_WIRE,
169+
resource_config={"scheme": "exact", "network": "eip155:8453", "payTo": "0xabc"},
170+
resource_meta={"url": "https://x/person/base/no-pii", "description": "t", "mimeType": "application/json"},
171+
)
172+
assert isinstance(result, ProcessX402SettleSuccess)
173+
for mock in (server.verify_payment, server.settle_payment):
174+
forwarded = mock.call_args[0][0]
175+
assert forwarded == {"x402Version": 2, "payload": _INNER, "accepted": _ACCEPTED}
176+
assert "accepted" in forwarded
177+
assert "extensions" not in forwarded
178+
assert "resource" not in forwarded

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)