From b95ca200016617a7329815d45745207974343961 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 5 May 2026 08:53:32 -0700 Subject: [PATCH 1/2] feat(x402): public helpers for the 402-emit path + probe extra.name fix (1.3.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts up the boilerplate every Python merchant currently inlines for emitting x402 challenges and ports the discovery probe to match the actual on-chain USDC contract names. New public exports - build_x402_accepts_for_402(server, *, network, price, pay_to, ...) -> list[dict] Wraps server.build_payment_requirements() so merchants don't import x402's ResourceConfig type, don't model_dump(by_alias=True) each Pydantic PaymentRequirements, and don't hardcode extra.name (which differs by network: base mainnet USDC returns "USD Coin", base sepolia returns "USDC" — wrong value silently breaks every signature at the facilitator). Falls through for dict-shaped requirements (older x402 / test stubs). - coerce_resource_config / coerce_payment_payload / settle_result_to_json_bytes Promoted from private. Used internally by process_x402_settle but useful to callers that need the same coerce on a custom verify+settle path. Probe / examples / tests - discovery/probe.py: sample_x402_accept_for_network now returns extra: {"name": "USD Coin", "version": "2"} for eip155:8453 and extra: {"name": "USDC", "version": "2"} for eip155:84532, matching the actual contract name() values. Was previously hardcoded "USDC" for both networks (correct only on sepolia). - examples/multi_rail_merchant.py + examples/api_provider.py: same fix; comment points at build_x402_accepts_for_402 as the cleaner production pattern. - tests/test_discovery.py: assertions updated to match the contract-derived values; added new sepolia-specific assertion. Tests: 724 passed, 95.16% coverage. Lint + ty clean. Doc updates: CLAUDE.md, README.md, mintlify python-commerce.mdx all gain a build_x402_accepts_for_402 snippet. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- README.md | 16 ++++++ agentscore_commerce/discovery/probe.py | 6 +- agentscore_commerce/payment/__init__.py | 8 +++ agentscore_commerce/payment/x402_server.py | 65 ++++++++++++++++++++++ agentscore_commerce/payment/x402_settle.py | 12 ++-- examples/api_provider.py | 12 +++- examples/multi_rail_merchant.py | 13 ++++- pyproject.toml | 2 +- tests/test_discovery.py | 6 +- tests/test_payment_servers.py | 50 +++++++++++++++++ uv.lock | 2 +- 12 files changed, 179 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2a73b40..e68ad32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.9` peer dep with v1+v2 dual-register + bazaar extension; for `facilitator="coinbase"` mints per-endpoint CDP JWTs via `cdp-sdk` (install with `coinbase` extra) and points HTTPFacilitatorClient at `api.cdp.coinbase.com/platform/v2/x402` — bare `x402Facilitator()` is empty and the CDP docs' env-var-only snippet does NOT auto-auth), `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.payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `create_x402_server` (wraps `x402[evm]>=2.9` + `cdp-sdk` for `facilitator="coinbase"`; install via the `coinbase` extra), `build_x402_accepts_for_402` (build the 402's `accepts[]` from the registered scheme — derives the right `extra.name` per network), `process_x402_settle` (verify+settle in one call), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6`), 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 | diff --git a/README.md b/README.md index 83be406..33cd918 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,22 @@ await simulate_deposit_if_test_mode(SimulateDepositIfTestModeInput( )) ``` +## Build the x402 accepts entry for the 402 challenge + +```python +from agentscore_commerce.payment import build_x402_accepts_for_402 + +x402_accepts = build_x402_accepts_for_402( + x402_server, + network=X402_BASE, + price=f"${total_usd}", + pay_to=os.environ["TREASURY_BASE_RECIPIENT"], + max_timeout_seconds=300, +) +``` + +Returns a list of plain dicts ready for the 402 body's `accepts[]`. `extra.name` is derived from the registered scheme metadata so the EIP-712 domain matches the on-chain USDC contract. + ## Drop-in 402 + settle (x402) ```python diff --git a/agentscore_commerce/discovery/probe.py b/agentscore_commerce/discovery/probe.py index 4d7a4f7..de3b4cd 100644 --- a/agentscore_commerce/discovery/probe.py +++ b/agentscore_commerce/discovery/probe.py @@ -39,7 +39,11 @@ def sample_x402_accept_for_network(caip2: str, amount_atomic: str = "1000000") - "asset": USDC.base.mainnet.address, "payTo": _ZERO_EVM_PAYTO, "maxTimeoutSeconds": 300, - "extra": {"name": "USDC", "version": "2"}, + # ``extra.name`` mirrors the on-chain USDC contract's ``name()`` return value + # because EIP-712 domain hashes include this string. Wrong name → every + # signed payload fails facilitator verify with ``invalid_exact_evm_payload_signature``. + # Base mainnet USDC returns "USD Coin"; base sepolia USDC returns "USDC". + "extra": {"name": "USD Coin", "version": "2"}, } if caip2 == networks.base.sepolia.caip2: return { diff --git a/agentscore_commerce/payment/__init__.py b/agentscore_commerce/payment/__init__.py index 844f8e3..208440f 100644 --- a/agentscore_commerce/payment/__init__.py +++ b/agentscore_commerce/payment/__init__.py @@ -52,6 +52,7 @@ CustomScheme, X402FacilitatorChoice, X402SymbolicRail, + build_x402_accepts_for_402, create_x402_server, ) from agentscore_commerce.payment.x402_settle import ( @@ -61,7 +62,10 @@ ProcessX402SettleResult, ProcessX402SettleSuccess, classify_x402_settle_result, + coerce_payment_payload, + coerce_resource_config, process_x402_settle, + settle_result_to_json_bytes, ) from agentscore_commerce.payment.x402_validation import ( X402_SUPPORTED_BASE_NETWORKS, @@ -114,7 +118,10 @@ "build_payment_directive", "build_payment_headers", "build_payment_request_blob", + "build_x402_accepts_for_402", "classify_x402_settle_result", + "coerce_payment_payload", + "coerce_resource_config", "create_mppx_server", "create_x402_server", "dispatch_settlement_by_network", @@ -130,6 +137,7 @@ "rails", "read_x402_payment_header", "register_x402_schemes_v1_v2", + "settle_result_to_json_bytes", "settlement_override_header", "validate_x402_network_config", "verify_x402_request", diff --git a/agentscore_commerce/payment/x402_server.py b/agentscore_commerce/payment/x402_server.py index 8167ae8..263821b 100644 --- a/agentscore_commerce/payment/x402_server.py +++ b/agentscore_commerce/payment/x402_server.py @@ -266,10 +266,75 @@ async def create_x402_server( return server +def build_x402_accepts_for_402( + server: Any, + *, + network: str, + price: str, + pay_to: str, + scheme: str = "exact", + max_timeout_seconds: int = 300, + extensions: list[str] | None = None, +) -> list[dict[str, Any]]: + """Build x402 ``accepts[]`` entries for a 402 challenge body. + + Wraps ``server.build_payment_requirements(...)`` so merchants don't have to: + + 1. Import ``x402.schemas.config.ResourceConfig`` themselves + 2. Remember to call ``model_dump(by_alias=True, mode="json")`` on each Pydantic + requirement so the surrounding JSON response can serialize it + 3. Hardcode ``extra`` (which differs by the actual on-chain contract: base mainnet + USDC has ``name="USD Coin"``, base sepolia USDC has ``name="USDC"`` — EIP-712 + domain hashes differ, so getting this wrong silently breaks every signature + verify at the facilitator) + + Returns a list of plain dicts in the shape that x402 expects on the wire — drop + them straight into the ``accepts`` field of the 402 challenge body. + + Raises ``Exception`` if the underlying ``build_payment_requirements`` raises; + callers should wrap with ``try/except`` and either omit x402 from the 402 or + surface a 5xx (depending on whether other rails are advertised). + """ + config_cls_module = _import_optional("x402.schemas.config") + config_cls = getattr(config_cls_module, "ResourceConfig", None) if config_cls_module else None + if config_cls is None: + msg = "x402 not installed — run `pip install 'x402[evm,fastapi]>=2.9,<3'` to use build_x402_accepts_for_402." + raise ImportError(msg) + config = config_cls( + scheme=scheme, + network=network, + price=price, + pay_to=pay_to, + max_timeout_seconds=max_timeout_seconds, + ) + requirements = ( + server.build_payment_requirements(config, extensions) + if extensions + else server.build_payment_requirements(config) + ) + # Pydantic ``PaymentRequirements`` is the live shape under x402 2.9+. Older + # versions (and test stubs) return plain dicts that already match the wire form. + out: list[dict[str, Any]] = [] + for req in requirements: + model_dump = getattr(req, "model_dump", None) + if callable(model_dump): + out.append(model_dump(by_alias=True, mode="json")) + elif isinstance(req, dict): + out.append(dict(req)) + else: + msg = ( + f"build_payment_requirements returned {type(req).__name__}; expected a " + "Pydantic PaymentRequirements or a dict." + ) + raise TypeError(msg) + return out + + __all__ = [ "CreateX402ServerOptions", "CustomScheme", "X402FacilitatorChoice", "X402SymbolicRail", + "build_x402_accepts_for_402", "create_x402_server", ] diff --git a/agentscore_commerce/payment/x402_settle.py b/agentscore_commerce/payment/x402_settle.py index bdc6b0d..5b46c47 100644 --- a/agentscore_commerce/payment/x402_settle.py +++ b/agentscore_commerce/payment/x402_settle.py @@ -189,7 +189,7 @@ def classify_x402_settle_result(result: ProcessX402SettleResult) -> ClassifiedX4 return None -def _coerce_resource_config(config: Any) -> Any: +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 @@ -220,7 +220,7 @@ def _coerce_resource_config(config: Any) -> Any: return config -def _coerce_payment_payload(payload: Any) -> Any: +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 @@ -252,8 +252,8 @@ def _coerce_payment_payload(payload: Any) -> Any: 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) + resource_config = coerce_resource_config(input.resource_config) + payload = coerce_payment_payload(input.payload) try: built_requirements = server.build_payment_requirements(resource_config) @@ -321,7 +321,7 @@ async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402Settl settle_result = await server.settle_payment(payload, matched_requirement) payment_response_header: str | None = None if settle_result is not None: - payment_response_header = base64.b64encode(_settle_result_to_json_bytes(settle_result)).decode() + payment_response_header = base64.b64encode(settle_result_to_json_bytes(settle_result)).decode() return ProcessX402SettleSuccess( matched_requirement=matched_requirement, settle_result=settle_result, @@ -332,7 +332,7 @@ async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402Settl return ProcessX402SettleFailure(phase="settle_failed", error=err, matched_requirement=matched_requirement) -def _settle_result_to_json_bytes(settle_result: Any) -> bytes: +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 diff --git a/examples/api_provider.py b/examples/api_provider.py index 8939254..14c6c1f 100644 --- a/examples/api_provider.py +++ b/examples/api_provider.py @@ -128,8 +128,16 @@ async def search(request: Request): "payTo": os.environ["X402_BASE_RECIPIENT"], "maxTimeoutSeconds": 300, # EIP-712 domain required by every x402 EVM client to sign - # EIP-3009 TransferWithAuthorization. - "extra": {"name": "USDC", "version": "2"}, + # EIP-3009 TransferWithAuthorization. ``name`` MUST match the + # on-chain USDC contract's ``name()`` — base mainnet returns + # "USD Coin", base sepolia returns "USDC". Wrong value silently + # breaks signature verify at the facilitator. Production code + # should use ``build_x402_accepts_for_402(server, ...)`` which + # derives ``extra`` from the registered scheme metadata. + "extra": { + "name": "USD Coin" if X402_BASE_NETWORK.split(":")[-1] == "8453" else "USDC", + "version": "2", + }, }, ] return JSONResponse( diff --git a/examples/multi_rail_merchant.py b/examples/multi_rail_merchant.py index 9158d97..01ec21b 100644 --- a/examples/multi_rail_merchant.py +++ b/examples/multi_rail_merchant.py @@ -265,8 +265,17 @@ async def purchase(request: Request, assess: dict = Depends(get_assess_data)): "payTo": deposit_addresses["base"], "maxTimeoutSeconds": 300, # EIP-712 domain — required by every x402 EVM client to - # sign EIP-3009 TransferWithAuthorization. - "extra": {"name": "USDC", "version": "2"}, + # sign EIP-3009 TransferWithAuthorization. ``name`` MUST + # match the on-chain USDC contract's ``name()``: base mainnet + # USDC returns "USD Coin", base sepolia returns "USDC". + # Wrong value → every signature fails facilitator verify + # (EIP-712 domain hash includes this string). The cleaner + # pattern is ``build_x402_accepts_for_402(server, ...)`` which + # derives ``extra`` from the registered scheme metadata. + "extra": { + "name": "USD Coin" if networks.base.mainnet.caip2 == X402_BASE_NETWORK else "USDC", + "version": "2", + }, }, { "scheme": "exact", diff --git a/pyproject.toml b/pyproject.toml index d124a25..b579cc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "1.3.3" +version = "1.3.4" 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" diff --git a/tests/test_discovery.py b/tests/test_discovery.py index af9aaf3..5dc68e3 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -176,7 +176,9 @@ def test_sample_accept_base_mainnet() -> None: assert e is not None assert e["network"] == "eip155:8453" assert e["scheme"] == "exact" - assert e["extra"] == {"name": "USDC", "version": "2"} + # Base mainnet USDC's contract returns ``name() == "USD Coin"``. ``extra.name`` + # mirrors that for EIP-712 domain-hash parity with what the facilitator validates. + assert e["extra"] == {"name": "USD Coin", "version": "2"} def test_sample_accept_base_sepolia() -> None: @@ -185,6 +187,8 @@ def test_sample_accept_base_sepolia() -> None: e = sample_x402_accept_for_network("eip155:84532") assert e is not None assert e["network"] == "eip155:84532" + # Base sepolia USDC contract returns ``name() == "USDC"`` (differs from mainnet). + assert e["extra"] == {"name": "USDC", "version": "2"} def test_sample_accept_solana_mainnet() -> None: diff --git a/tests/test_payment_servers.py b/tests/test_payment_servers.py index f8ac29d..b956b4e 100644 --- a/tests/test_payment_servers.py +++ b/tests/test_payment_servers.py @@ -91,6 +91,56 @@ async def test_create_x402_server_coinbase_without_creds_raises(monkeypatch: pyt await create_x402_server(facilitator="coinbase", rails=["x402-base-mainnet"], initialize=False) +@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed") +def test_build_x402_accepts_for_402_returns_dicts_from_typed_requirements() -> None: + """``build_x402_accepts_for_402`` wraps ``server.build_payment_requirements`` and + returns the requirements as wire-shape dicts (via ``model_dump(by_alias=True, mode="json")``) + so merchants can drop them straight into the 402 response body without importing + Pydantic types or remembering to serialize. + """ + from x402.schemas import PaymentRequirements + + from agentscore_commerce.payment import build_x402_accepts_for_402 + + captured: dict = {} + + class _CapturingServer: + def build_payment_requirements(self, config, _ext=None): + captured["config"] = config + return [ + PaymentRequirements( + scheme="exact", + network="eip155:8453", + amount="100000", + asset="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + pay_to="0x000000000000000000000000000000000000dEaD", + max_timeout_seconds=300, + extra={"name": "USD Coin", "version": "2"}, + ) + ] + + accepts = build_x402_accepts_for_402( + _CapturingServer(), + network="eip155:8453", + price="$0.10", + pay_to="0x000000000000000000000000000000000000dEaD", + ) + # Caller-side: a typed ResourceConfig is passed to build_payment_requirements. + cfg = captured["config"] + assert cfg.network == "eip155:8453" + assert cfg.pay_to == "0x000000000000000000000000000000000000dEaD" + assert cfg.max_timeout_seconds == 300 + # Returned shape: wire-form dicts, not Pydantic models. + assert isinstance(accepts, list) + assert len(accepts) == 1 + assert isinstance(accepts[0], dict) + assert accepts[0]["network"] == "eip155:8453" + # Camel-case keys (by_alias=True) — facilitator + clients expect this shape. + assert accepts[0]["payTo"] == "0x000000000000000000000000000000000000dEaD" + assert accepts[0]["maxTimeoutSeconds"] == 300 + assert accepts[0]["extra"] == {"name": "USD Coin", "version": "2"} + + @pytest.mark.skipif(not _MPPX_INSTALLED or not _TEMPO_INSTALLED, reason="pympp[tempo] not installed") @pytest.mark.asyncio async def test_create_mppx_server_tempo_returns_mpp_instance() -> None: diff --git a/uv.lock b/uv.lock index 00b5162..49b20fd 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [[package]] name = "agentscore-commerce" -version = "1.3.3" +version = "1.3.4" source = { editable = "." } dependencies = [ { name = "agentscore-py" }, From a9e8c8286217c5c324b3074054bbe4dd1ea73a7b Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 5 May 2026 08:57:30 -0700 Subject: [PATCH 2/2] example(multi_rail): use build_x402_accepts_for_402 helper instead of inline Replaces the hand-rolled x402-base accept (with its conditional ``extra.name`` based on network) with one call to ``build_x402_accepts_for_402``. The helper fills in the right ``extra.name`` from the registered scheme metadata. Solana stays inline because it goes through MPP ``solana/charge``, not x402's exact scheme. ``api_provider.py`` keeps the inline pattern (smallest possible example) with a comment pointing at the helper as the production pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/multi_rail_merchant.py | 37 ++++++++++----------------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/examples/multi_rail_merchant.py b/examples/multi_rail_merchant.py index 01ec21b..794eb58 100644 --- a/examples/multi_rail_merchant.py +++ b/examples/multi_rail_merchant.py @@ -69,6 +69,7 @@ ProcessX402SettleInput, ValidateX402NetworkConfigInput, VerifyX402RequestInput, + build_x402_accepts_for_402, networks, process_x402_settle, validate_x402_network_config, @@ -250,33 +251,17 @@ async def purchase(request: Request, assess: dict = Depends(get_assess_data)): ), x402=PaymentRequiredHeaderInput( x402_version=2, + # Base accept comes from the registered x402 scheme — `extra` (incl. the + # network-correct USDC `name`) is filled in automatically. Solana goes + # through MPP `solana/charge` not x402's exact scheme, so it stays inline. accepts=[ - { - "scheme": "exact", - "network": X402_BASE_NETWORK, - "amount": str(round(float(total_usd) * 1_000_000)), - # Asset must match the configured network — sepolia + mainnet - # use different USDC contracts. - "asset": ( - USDC.base.sepolia.address - if networks.base.sepolia.caip2 == X402_BASE_NETWORK - else USDC.base.mainnet.address - ), - "payTo": deposit_addresses["base"], - "maxTimeoutSeconds": 300, - # EIP-712 domain — required by every x402 EVM client to - # sign EIP-3009 TransferWithAuthorization. ``name`` MUST - # match the on-chain USDC contract's ``name()``: base mainnet - # USDC returns "USD Coin", base sepolia returns "USDC". - # Wrong value → every signature fails facilitator verify - # (EIP-712 domain hash includes this string). The cleaner - # pattern is ``build_x402_accepts_for_402(server, ...)`` which - # derives ``extra`` from the registered scheme metadata. - "extra": { - "name": "USD Coin" if networks.base.mainnet.caip2 == X402_BASE_NETWORK else "USDC", - "version": "2", - }, - }, + *build_x402_accepts_for_402( + x402_server, + network=X402_BASE_NETWORK, + price=f"${total_usd}", + pay_to=deposit_addresses["base"], + max_timeout_seconds=300, + ), { "scheme": "exact", "network": SOLANA_NETWORK_CAIP2,