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
53 changes: 49 additions & 4 deletions agentscore_commerce/payment/x402_settle.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,40 @@ def _coerce_resource_config(config: Any) -> Any:
return config


def _coerce_payment_payload(payload: Any) -> Any:
"""Best-effort dict → x402 ``PaymentPayload`` (v1 or v2) coercion.

``verify_x402_request`` returns ``payload`` as a plain dict (the result of
``json.loads(base64.b64decode(X-Payment))``), but x402 2.9's
``server.verify_payment`` / ``server.settle_payment`` call ``payload.get_scheme()``
and other typed-model methods on it. Without coercion, the dict raises
``AttributeError("'dict' object has no attribute 'get_scheme'")`` on the verify leg.

Routes by the ``x402Version`` field: ``1`` → ``PaymentPayloadV1`` (flat shape with
top-level ``scheme`` / ``network``); anything else → ``PaymentPayload`` (v2 shape
nested under ``accepted``). Falls back to the original dict on any failure so callers
that already pass typed instances or unusual shapes still flow through unchanged.
"""
if not isinstance(payload, dict):
return payload
try:
from x402.schemas import PaymentPayload
from x402.schemas.v1 import PaymentPayloadV1
except ImportError:
return payload
version = payload.get("x402Version")
model = PaymentPayloadV1 if version == 1 else PaymentPayload
try:
return model.model_validate(payload)
except Exception:
return payload


async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402SettleResult:
"""Run the x402 verify→settle flow and return a tagged outcome."""
server = input.x402_server
resource_config = _coerce_resource_config(input.resource_config)
payload = _coerce_payment_payload(input.payload)

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

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

try:
settle_result = await server.settle_payment(input.payload, matched_requirement)
settle_result = await server.settle_payment(payload, matched_requirement)
payment_response_header: str | None = None
if settle_result is not None:
payload_bytes = json.dumps(settle_result, separators=(",", ":")).encode()
payment_response_header = base64.b64encode(payload_bytes).decode()
payment_response_header = base64.b64encode(_settle_result_to_json_bytes(settle_result)).decode()
return ProcessX402SettleSuccess(
matched_requirement=matched_requirement,
settle_result=settle_result,
Expand All @@ -301,3 +330,19 @@ async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402Settl
)
except Exception as err:
return ProcessX402SettleFailure(phase="settle_failed", error=err, matched_requirement=matched_requirement)


def _settle_result_to_json_bytes(settle_result: Any) -> bytes:
"""Serialize the settle result to a base64-friendly JSON byte string.

x402 2.9's ``settle_payment`` returns a Pydantic ``SettleResponse`` model that
``json.dumps`` rejects with ``TypeError: Object of type SettleResponse is not
JSON serializable``. Use ``model_dump_json(by_alias=True)`` for Pydantic models
(so emitted keys match the wire shape — ``errorReason`` / ``errorMessage`` rather
than the snake_case attrs) and fall through to ``json.dumps`` for plain dicts
(used by older x402 / test stubs).
"""
model_dump_json = getattr(settle_result, "model_dump_json", None)
if callable(model_dump_json):
return model_dump_json(by_alias=True).encode()
return json.dumps(settle_result, separators=(",", ":")).encode()
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 = "1.3.2"
version = "1.3.3"
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
214 changes: 214 additions & 0 deletions tests/test_lifted_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,220 @@ async def settle_payment(self, _payload: object, _req: object) -> dict:
assert captured["cfg"] is typed


@pytest.mark.asyncio
async def test_process_x402_settle_coerces_dict_payload_v2_to_typed_payment_payload():
"""Same fix as resource_config: ``verify_x402_request`` returns ``payload`` as a
plain dict (the result of ``json.loads(base64.b64decode(X-Payment))``); x402 2.9's
``server.verify_payment`` calls ``payload.get_scheme()`` and other typed-model methods
on it. Coerce dicts → ``PaymentPayload`` (or ``PaymentPayloadV1`` when ``x402Version=1``)
so the helper doesn't ``AttributeError`` at the verify leg.
"""
captured: dict = {}

class _CapturingServer:
def build_payment_requirements(self, _cfg: object, _ext: object = None) -> list:
return [{"id": "req1"}]

async def verify_payment(self, payload: object, _req: object) -> dict:
captured["verify_payload"] = payload
return {"is_valid": True}

async def settle_payment(self, payload: object, _req: object) -> dict:
captured["settle_payload"] = payload
return {"tx_hash": "0xabc"}

payload_dict = {
"x402Version": 2,
"accepted": {
"scheme": "exact",
"network": "eip155:8453",
"amount": "100000",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "0x000000000000000000000000000000000000dEaD",
"maxTimeoutSeconds": 300,
},
"payload": {
"authorization": {
"from": "0xeb2Ca790F72787c7e61bC6c861353a1e4ACDFCa5",
"to": "0x000000000000000000000000000000000000dEaD",
"value": "100000",
"validAfter": 0,
"validBefore": 9999999999,
"nonce": "0x" + "aa" * 32,
},
"signature": "0x" + "cc" * 65,
},
}
res = await process_x402_settle(
ProcessX402SettleInput(
x402_server=_CapturingServer(),
payload=payload_dict,
resource_config={
"scheme": "exact",
"network": "eip155:8453",
"price": "$0.10",
"payTo": "0x" + "00" * 19 + "dE" + "aD",
"maxTimeoutSeconds": 300,
},
resource_meta=_RESOURCE_META,
)
)
assert isinstance(res, ProcessX402SettleSuccess)
# Both verify and settle legs received the typed Pydantic model, not the raw dict.
verify_payload = captured["verify_payload"]
settle_payload = captured["settle_payload"]
assert type(verify_payload).__name__ == "PaymentPayload"
assert type(settle_payload).__name__ == "PaymentPayload"
# The typed model exposes the get_scheme() method that x402's facilitator calls.
assert hasattr(verify_payload, "get_scheme")
assert verify_payload.get_scheme() == "exact"


@pytest.mark.asyncio
async def test_process_x402_settle_serializes_pydantic_settle_result_to_payment_response_header():
"""x402 2.9's ``settle_payment`` returns a Pydantic ``SettleResponse`` model;
plain ``json.dumps`` rejects it with ``TypeError``. The helper must call
``model_dump_json(by_alias=True)`` so the X-Payment-Response header stays
base64'd JSON with the right wire keys (``errorReason`` not ``error_reason``).
"""
from x402.schemas import SettleResponse

pydantic_settle = SettleResponse(
success=True,
transaction="0xabc",
network="eip155:8453",
payer="0x000000000000000000000000000000000000dEaD",
amount=None,
)

class _PydanticSettleServer:
def build_payment_requirements(self, _cfg: object, _ext: object = None) -> list:
return [{"id": "req1"}]

async def verify_payment(self, _payload: object, _req: object) -> dict:
return {"is_valid": True}

async def settle_payment(self, _payload: object, _req: object) -> SettleResponse:
return pydantic_settle

res = await process_x402_settle(
ProcessX402SettleInput(
x402_server=_PydanticSettleServer(),
payload={},
resource_config={
"scheme": "exact",
"network": "eip155:8453",
"price": "$0.10",
"payTo": "0x" + "00" * 19 + "dE" + "aD",
"maxTimeoutSeconds": 300,
},
resource_meta=_RESOURCE_META,
)
)
assert isinstance(res, ProcessX402SettleSuccess)
assert res.payment_response_header is not None
decoded = json.loads(base64.b64decode(res.payment_response_header).decode())
assert decoded["success"] is True
assert decoded["transaction"] == "0xabc"
assert decoded["network"] == "eip155:8453"
# by_alias=True: wire shape uses errorReason / errorMessage (camelCase) — not snake_case.
assert "errorReason" in decoded


@pytest.mark.asyncio
async def test_process_x402_settle_serializes_dict_settle_result_for_legacy_stubs():
"""Plain-dict settle results (from older x402 / test stubs) still serialize."""

class _DictSettleServer:
def build_payment_requirements(self, _cfg: object, _ext: object = None) -> list:
return [{"id": "req1"}]

async def verify_payment(self, _payload: object, _req: object) -> dict:
return {"is_valid": True}

async def settle_payment(self, _payload: object, _req: object) -> dict:
return {"success": True, "transaction": "0xdef"}

res = await process_x402_settle(
ProcessX402SettleInput(
x402_server=_DictSettleServer(),
payload={},
resource_config={
"scheme": "exact",
"network": "eip155:8453",
"price": "$0.10",
"payTo": "0x" + "00" * 19 + "dE" + "aD",
"maxTimeoutSeconds": 300,
},
resource_meta=_RESOURCE_META,
)
)
assert isinstance(res, ProcessX402SettleSuccess)
assert res.payment_response_header is not None
decoded = json.loads(base64.b64decode(res.payment_response_header).decode())
assert decoded == {"success": True, "transaction": "0xdef"}


@pytest.mark.asyncio
async def test_process_x402_settle_passes_typed_payment_payload_unchanged():
"""If the caller already provides a typed PaymentPayload, pass it through unchanged."""
from x402.schemas import PaymentPayload

typed = PaymentPayload.model_validate(
{
"x402Version": 2,
"accepted": {
"scheme": "exact",
"network": "eip155:8453",
"amount": "100000",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "0x000000000000000000000000000000000000dEaD",
"maxTimeoutSeconds": 300,
},
"payload": {
"authorization": {
"from": "0xeb2Ca790F72787c7e61bC6c861353a1e4ACDFCa5",
"to": "0x000000000000000000000000000000000000dEaD",
"value": "100000",
"validAfter": 0,
"validBefore": 9999999999,
"nonce": "0x" + "aa" * 32,
},
"signature": "0x" + "cc" * 65,
},
}
)
captured: dict = {}

class _CapturingServer:
def build_payment_requirements(self, _cfg: object, _ext: object = None) -> list:
return [{"id": "req1"}]

async def verify_payment(self, payload: object, _req: object) -> dict:
captured["payload"] = payload
return {"is_valid": True}

async def settle_payment(self, _payload: object, _req: object) -> dict:
return {"tx_hash": "0xabc"}

res = await process_x402_settle(
ProcessX402SettleInput(
x402_server=_CapturingServer(),
payload=typed,
resource_config={
"scheme": "exact",
"network": "eip155:8453",
"price": "$0.10",
"payTo": "0x" + "00" * 19 + "dE" + "aD",
"maxTimeoutSeconds": 300,
},
resource_meta=_RESOURCE_META,
)
)
assert isinstance(res, ProcessX402SettleSuccess)
assert captured["payload"] is typed


# ─────────────────────────────────────────────────────────────────────────────
# process_x402_settle: facilitator_error wrap
# ─────────────────────────────────────────────────────────────────────────────
Expand Down
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.

Loading