Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions agentscore_commerce/payment/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
coerce_resource_config,
process_x402_settle,
settle_result_to_json_bytes,
strip_unsigned_x402_payload_fields,
)
from agentscore_commerce.payment.x402_validation import (
X402_SUPPORTED_BASE_NETWORKS,
Expand Down Expand Up @@ -164,6 +165,7 @@
"resolve_recipient",
"settle_result_to_json_bytes",
"settlement_override_header",
"strip_unsigned_x402_payload_fields",
"usd_to_atomic",
"validate_x402_network_config",
"verify_x402_request",
Expand Down
29 changes: 28 additions & 1 deletion agentscore_commerce/payment/x402_settle.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,31 @@ def coerce_payment_payload(payload: Any) -> Any:
return payload


def strip_unsigned_x402_payload_fields(payload: Any) -> Any:
"""Drop the non-signed ``extensions`` + ``resource`` blocks from a decoded X-Payment payload.

x402 clients echo the 402 challenge's ``extensions`` (Bazaar input schema) and ``resource``
into the payload alongside the signed ``payload`` + ``accepted``. Neither is part of the
EIP-3009 signature (which covers only ``payload.authorization``). 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 and CDP rejects it
(``must match one of [x402V2PaymentPayload, x402V1PaymentPayload]``). Routes whose echoed
Bazaar schema is large fail while small ones slip through, so it presents as route-dependent
but is one shape bug.

``accepted`` (which ``verify_x402_request`` reads for network/payTo, and which CDP requires)
and every other field stay intact; only the two echoed challenge blocks are dropped, before
the payload is coerced to the typed model whose ``model_dump`` reaches the facilitator.
Non-dict payloads, and payloads carrying neither field, pass through unchanged.
"""
if not isinstance(payload, dict):
return payload
if "extensions" not in payload and "resource" not in payload:
return payload
return {k: v for k, v in payload.items() if k not in ("extensions", "resource")}


async def process_x402_settle(
*,
x402_server: Any,
Expand All @@ -322,7 +347,9 @@ async def process_x402_settle(
"""
server = x402_server
coerced_config = coerce_resource_config(resource_config)
coerced_payload = coerce_payment_payload(payload)
# Drop the echoed extensions/resource blocks before coercion: CDP's verify schema admits
# neither, so their presence makes the payload match no union branch and settle fails.
coerced_payload = coerce_payment_payload(strip_unsigned_x402_payload_fields(payload))

try:
built_requirements = server.build_payment_requirements(coerced_config)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "agentscore-commerce"
version = "2.5.9"
version = "2.5.10"
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."
readme = "README.md"
license = "MIT"
Expand Down
53 changes: 53 additions & 0 deletions tests/test_x402_settle_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
classify_x402_settle_result,
process_x402_settle,
settle_result_to_json_bytes,
strip_unsigned_x402_payload_fields,
)


Expand Down Expand Up @@ -123,3 +124,55 @@ def test_settle_result_to_json_bytes() -> None:
out = settle_result_to_json_bytes({"a": 1, "b": "two"})
assert isinstance(out, bytes)
assert b'"a"' in out


_ACCEPTED = {"scheme": "exact", "network": "eip155:8453", "payTo": "0xabc"}
_INNER = {"authorization": {"from": "0xsigner", "to": "0xabc"}, "signature": "0xdeadbeef"}
_WIRE = {
"x402Version": 2,
"payload": _INNER,
"accepted": _ACCEPTED,
"extensions": {"bazaar": {"schema": {"type": "object", "properties": {"phone": {"type": "string"}}}}},
"resource": {"url": "https://x/person/base/no-pii", "description": "d" * 600, "tags": ["person"]},
}


def test_strip_drops_extensions_and_resource_keeps_accepted() -> None:
assert strip_unsigned_x402_payload_fields(_WIRE) == {
"x402Version": 2,
"payload": _INNER,
"accepted": _ACCEPTED,
}


def test_strip_returns_same_object_when_neither_field_present() -> None:
lean = {"x402Version": 2, "payload": _INNER, "accepted": _ACCEPTED}
assert strip_unsigned_x402_payload_fields(lean) is lean


def test_strip_handles_single_field_and_non_dict() -> None:
assert strip_unsigned_x402_payload_fields({"payload": _INNER, "resource": {"url": "x"}}) == {"payload": _INNER}
assert strip_unsigned_x402_payload_fields({"payload": _INNER, "extensions": {"bazaar": {}}}) == {"payload": _INNER}
assert strip_unsigned_x402_payload_fields(None) is None
assert strip_unsigned_x402_payload_fields("not-a-dict") == "not-a-dict"


@pytest.mark.asyncio
async def test_process_x402_settle_strips_bloat_before_facilitator() -> None:
# Incomplete `accepted` makes coerce_payment_payload leave the payload a plain dict, so we
# can assert directly on what reaches verify_payment / settle_payment. The strip must have
# removed extensions/resource and kept accepted (which CDP's v2 schema requires).
server = _make_server()
result = await process_x402_settle(
x402_server=server,
payload=_WIRE,
resource_config={"scheme": "exact", "network": "eip155:8453", "payTo": "0xabc"},
resource_meta={"url": "https://x/person/base/no-pii", "description": "t", "mimeType": "application/json"},
)
assert isinstance(result, ProcessX402SettleSuccess)
for mock in (server.verify_payment, server.settle_payment):
forwarded = mock.call_args[0][0]
assert forwarded == {"x402Version": 2, "payload": _INNER, "accepted": _ACCEPTED}
assert "accepted" in forwarded
assert "extensions" not in forwarded
assert "resource" not in forwarded
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.