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: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Every helper is extracted from a real consumer, not speculated.
| Submodule | What it is |
|---|---|
| `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware (KYC, age, sanctions, jurisdiction) |
| `agentscore_commerce.payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `create_x402_server` (wraps official `x402[evm]>=2.8` peer dep with v1+v2 dual-register + bazaar extension), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6` peer dep with Tempo charge/session + Stripe SPT helpers), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header |
| `agentscore_commerce.payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `create_x402_server` (wraps official `x402[evm]>=2.8` peer dep with v1+v2 dual-register + bazaar extension), `process_x402_settle` (single-call verify+settle wrapper around `x402ResourceServer`'s real 2.9 API: `build_payment_requirements` → `verify_payment` → `settle_payment`; auto-coerces dict `resource_config` with camelCase keys → typed `ResourceConfig`), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6` peer dep with Tempo charge/session + Stripe SPT helpers), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header |
| `agentscore_commerce.discovery` | Discovery probe, Bazaar wrapper, `/.well-known/mpp.json`, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), OpenAPI snippets, `NoindexNonDiscoveryMiddleware` ASGI middleware |
| `agentscore_commerce.challenge` | 402-body builders: accepted_methods, identity_metadata, how_to_pay, agent_instructions, build_402_body, `build_validation_error` (4xx body builder) |
| `agentscore_commerce.stripe_multichain` | Multichain PaymentIntent helper, deposit-address lookup, testnet simulator, mppx Stripe wrapper |
Expand Down
130 changes: 95 additions & 35 deletions agentscore_commerce/payment/x402_settle.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
"""``process_x402_settle``: single-call x402 verify+settle for merchants.

Wraps the four x402-server steps every x402-accepting merchant repeats:
Wraps the four x402-server steps every x402-accepting merchant repeats against
``x402.x402ResourceServer`` (sync ``build_payment_requirements`` + sync
``enrich_extensions`` + async ``verify_payment`` + async ``settle_payment``):

1. ``build_payment_requirements(resource_config)``: builds the requirement entries the
facilitator validates against
2. ``enrich_extensions(extension, transport_context)``: folds in Bazaar (or other)
extensions for the verify step
3. ``process_payment_request(payload, resource_config, resource_meta, extensions)``:
runs verify against the facilitator
2. ``enrich_extensions(declared, transport_context)``: folds in Bazaar (or other)
extensions for the verify step (only when ``input.extension`` is supplied)
3. ``verify_payment(payload, matched_requirement)``: runs verify against the facilitator
4. ``settle_payment(payload, matched_requirement)``: settles on-chain

Accepts ``resource_config`` as either a ``dict`` (JS-style with ``payTo`` /
``maxTimeoutSeconds`` camelCase keys) or an ``x402.schemas.config.ResourceConfig``;
dicts are coerced before the build step so callers don't have to import the x402 type.

Returns a tagged result so the caller can map errors to merchant-shaped responses
without owning the orchestration boilerplate. Use :func:`classify_x402_settle_result`
to map the tagged result to a recommended HTTP response.
Expand Down Expand Up @@ -72,10 +77,10 @@ class ProcessX402SettleFailure:
``error`` server-side; map to a controlled 503 with
``payment_provider_unavailable``.
- ``facilitator_error``: facilitator raised during one of the verify-stage calls
(build requirements, extension enrich, or process_payment_request). Most common
cause: facilitator client rejects the configured network. Log raw ``error``
server-side; map to a controlled 503 so the agent can pick a different rail.
``step`` indicates which verify-stage call raised.
(build requirements, extension enrich, or verify_payment). Most common cause:
facilitator client rejects the configured network. Log raw ``error`` server-side;
map to a controlled 503 so the agent can pick a different rail. ``step`` indicates
which verify-stage call raised.
"""

phase: Literal["no_requirements", "verify_failed", "settle_failed", "facilitator_error"]
Expand All @@ -85,8 +90,8 @@ class ProcessX402SettleFailure:
error: Any = None
matched_requirement: Any = None
#: Populated only when ``phase == "facilitator_error"``. Indicates which verify-stage
#: call raised: ``"build_requirements"`` / ``"enrich_extensions"`` / ``"process_payment_request"``.
step: Literal["build_requirements", "enrich_extensions", "process_payment_request"] | None = None
#: call raised: ``"build_requirements"`` / ``"enrich_extensions"`` / ``"verify_payment"``.
step: Literal["build_requirements", "enrich_extensions", "verify_payment"] | None = None
extra: dict[str, Any] = field(default_factory=dict)


Expand Down Expand Up @@ -184,12 +189,44 @@ def classify_x402_settle_result(result: ProcessX402SettleResult) -> ClassifiedX4
return None


def _coerce_resource_config(config: Any) -> Any:
"""Best-effort dict → x402 ``ResourceConfig`` coercion.

Consumers ported from the JS / Hono stack often pass a plain dict with the JS-style
``payTo`` / ``maxTimeoutSeconds`` camelCase keys (the shape that ``processX402Settle``
in node-commerce accepts). x402's Python ``ResourceConfig`` is a Pydantic model with
``pay_to`` / ``max_timeout_seconds`` snake_case fields, and ``build_payment_requirements``
does ``config.network`` attribute access — so a raw dict raises
``AttributeError("'dict' object has no attribute 'network'")``. Coerce here so callers
can pass either shape.

Falls back to the original input on any failure (missing peer dep, validation error)
so caller-side typed instances still pass through unchanged.
"""
if not isinstance(config, dict):
return config
try:
from x402.schemas.config import ResourceConfig
except ImportError:
return config
coerced = dict(config)
if "payTo" in coerced and "pay_to" not in coerced:
coerced["pay_to"] = coerced.pop("payTo")
if "maxTimeoutSeconds" in coerced and "max_timeout_seconds" not in coerced:
coerced["max_timeout_seconds"] = coerced.pop("maxTimeoutSeconds")
try:
return ResourceConfig(**coerced)
except Exception:
return config


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)

try:
built_requirements = await server.build_payment_requirements(input.resource_config)
built_requirements = server.build_payment_requirements(resource_config)
except Exception as err:
return ProcessX402SettleFailure(phase="facilitator_error", step="build_requirements", error=err)
if not built_requirements:
Expand All @@ -199,32 +236,55 @@ async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402Settl
)
matched_requirement = built_requirements[0]

transport_context = input.transport_context
if transport_context is None:
path = urlparse(input.resource_meta["url"]).path
transport_context = {
"method": "POST",
"adapter": {"getPath": lambda: path},
"routePattern": path,
}

# Per-request extension enrichment runs only when a caller explicitly attaches one
# (e.g. the Bazaar discovery extension). x402 2.9 takes the enriched dict as the
# second argument to ``build_payment_requirements`` rather than as a verify-step
# input, but the fold happens at build time — so we replay the build with the
# enriched extensions and use those requirements going forward.
if input.extension is not None:
transport_context = input.transport_context
if transport_context is None:
path = urlparse(input.resource_meta["url"]).path
transport_context = {
"method": "POST",
"adapter": {"getPath": lambda: path},
"routePattern": path,
}
try:
enriched_ext = server.enrich_extensions(input.extension, transport_context)
except Exception as err:
return ProcessX402SettleFailure(phase="facilitator_error", step="enrich_extensions", error=err)
try:
built_requirements = server.build_payment_requirements(
resource_config, list(enriched_ext.keys()) if isinstance(enriched_ext, dict) else None
)
if built_requirements:
matched_requirement = built_requirements[0]
except Exception as err:
return ProcessX402SettleFailure(phase="facilitator_error", step="build_requirements", error=err)

# x402 2.9's ``x402ResourceServer`` exposes ``verify_payment(payload, requirements)``
# — not ``process_payment_request`` (a fictional method that earlier versions of this
# helper called and only ever worked against test stubs).
try:
enriched_ext = (
server.enrich_extensions(input.extension, transport_context) if input.extension is not None else None
)
verify_result = await server.verify_payment(input.payload, matched_requirement)
except Exception as err:
return ProcessX402SettleFailure(phase="facilitator_error", step="enrich_extensions", error=err)

try:
verify_result = await server.process_payment_request(
input.payload, input.resource_config, input.resource_meta, enriched_ext
return ProcessX402SettleFailure(phase="facilitator_error", step="verify_payment", error=err)

# x402's VerifyResponse exposes ``is_valid``; some stubs / older facilitators expose
# ``success``. Accept either.
is_valid = (
getattr(verify_result, "is_valid", None)
if hasattr(verify_result, "is_valid")
else (verify_result.get("is_valid") if isinstance(verify_result, dict) else None)
)
if is_valid is None:
is_valid = (
getattr(verify_result, "success", None)
if hasattr(verify_result, "success")
else (verify_result.get("success") if isinstance(verify_result, dict) else False)
)
except Exception as err:
return ProcessX402SettleFailure(phase="facilitator_error", step="process_payment_request", error=err)

if not getattr(
verify_result, "success", verify_result.get("success") if isinstance(verify_result, dict) else False
):
if not is_valid:
return ProcessX402SettleFailure(phase="verify_failed", verify_result=verify_result)

try:
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 = "1.3.0"
version = "1.3.1"
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
93 changes: 89 additions & 4 deletions tests/test_lifted_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,12 @@ async def test_verify_x402_rejects_solana_credential():


class _FakeServer:
"""Stubbed x402 server matching the x402 2.9 ``x402ResourceServer`` surface:
sync ``build_payment_requirements(config, extensions=None)`` + sync
``enrich_extensions(declared, transport_context)`` + async ``verify_payment(payload,
requirements)`` + async ``settle_payment(payload, requirements)``.
"""

def __init__(
self,
requirements: list | Exception,
Expand All @@ -351,7 +357,7 @@ def __init__(
self.settle_result = settle_result
self.enrich_result = enrich_result

async def build_payment_requirements(self, _cfg: object) -> list:
def build_payment_requirements(self, _cfg: object, _extensions: object = None) -> list:
if isinstance(self.requirements, Exception):
raise self.requirements
return self.requirements
Expand All @@ -361,7 +367,7 @@ def enrich_extensions(self, ext: object, _ctx: object) -> object:
raise self.enrich_result
return ext if self.enrich_result == "passthrough" else self.enrich_result

async def process_payment_request(self, _payload: object, _cfg: object, _meta: object, _ext: object) -> dict:
async def verify_payment(self, _payload: object, _req: object) -> dict:
if isinstance(self.verify_result, Exception):
raise self.verify_result
return self.verify_result
Expand Down Expand Up @@ -449,6 +455,85 @@ async def test_process_x402_settle_success_returns_payment_response_header():
assert decoded["tx_hash"] == "0xabc"


@pytest.mark.asyncio
async def test_process_x402_settle_coerces_dict_resource_config_with_camelcase_keys():
"""Consumers ported from the JS / Hono stack pass dicts with camelCase keys
(``payTo``, ``maxTimeoutSeconds``). Coerce them into x402's ResourceConfig so
``build_payment_requirements(config.network)`` doesn't ``AttributeError`` on a dict.
"""
captured: dict = {}

class _CapturingServer:
def build_payment_requirements(self, cfg: object, _ext: object = None) -> list:
captured["cfg"] = cfg
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 {"tx_hash": "0xabc"}

res = await process_x402_settle(
ProcessX402SettleInput(
x402_server=_CapturingServer(),
payload={},
resource_config={
"scheme": "exact",
"network": "eip155:8453",
"price": "$0.10",
"payTo": "0xa43d4e316ef5f430426cd1b454167e5f85e3f4f1",
"maxTimeoutSeconds": 300,
},
resource_meta=_RESOURCE_META,
)
)
assert isinstance(res, ProcessX402SettleSuccess)
# Coerced into x402's ResourceConfig (Pydantic model with snake_case attrs).
cfg = captured["cfg"]
assert hasattr(cfg, "network")
assert cfg.network == "eip155:8453"
assert cfg.pay_to == "0xa43d4e316ef5f430426cd1b454167e5f85e3f4f1"
assert cfg.max_timeout_seconds == 300


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

typed = ResourceConfig(
scheme="exact",
network="eip155:8453",
price="$0.10",
pay_to="0xa43d4e316ef5f430426cd1b454167e5f85e3f4f1",
max_timeout_seconds=300,
)
captured: dict = {}

class _CapturingServer:
def build_payment_requirements(self, cfg: object, _ext: object = None) -> list:
captured["cfg"] = cfg
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 {"tx_hash": "0xabc"}

res = await process_x402_settle(
ProcessX402SettleInput(
x402_server=_CapturingServer(),
payload={},
resource_config=typed,
resource_meta=_RESOURCE_META,
)
)
assert isinstance(res, ProcessX402SettleSuccess)
assert captured["cfg"] is typed


# ─────────────────────────────────────────────────────────────────────────────
# process_x402_settle: facilitator_error wrap
# ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -496,7 +581,7 @@ async def test_process_x402_settle_wraps_enrich_extensions_throws_as_facilitator


@pytest.mark.asyncio
async def test_process_x402_settle_wraps_process_payment_request_throws_as_facilitator_error():
async def test_process_x402_settle_wraps_verify_payment_throws_as_facilitator_error():
server = _FakeServer(
requirements=[{"id": "req1"}],
verify_result=RuntimeError("CDP facilitator: solana:devnet not supported"),
Expand All @@ -511,7 +596,7 @@ async def test_process_x402_settle_wraps_process_payment_request_throws_as_facil
)
assert isinstance(res, ProcessX402SettleFailure)
assert res.phase == "facilitator_error"
assert res.step == "process_payment_request"
assert res.step == "verify_payment"
assert isinstance(res.error, RuntimeError)


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