Skip to content

Commit dcb8041

Browse files
vvillait88claude
andauthored
fix(x402): wire Coinbase facilitator JWT auth (1.3.2) (#9)
## Summary `create_x402_server(facilitator="coinbase")` was passing a bare `x402Facilitator()` (an empty in-process facilitator with no schemes) to `x402ResourceServer`. Pre-existing bug — only surfaced after #8 fixed the dict→`ResourceConfig` coerce in `process_x402_settle`, which then unblocked the path through `build_payment_requirements` where the missing facilitator wiring raised: ``` SchemeNotFoundError("No scheme 'exact' registered for network 'eip155:8453'") ``` The Coinbase x402 facilitator at `api.cdp.coinbase.com` requires a **per-endpoint JWT** signed with the CDP API secret over `(method, host, path)`. The TS sibling `@coinbase/x402` ships that JWT minter (`createCdpAuthHeaders` → `generateJwt` from `@coinbase/cdp-sdk/auth`). There is no Python `coinbase-x402` package on PyPI. The official `docs.cdp.coinbase.com/x402/quickstart-for-sellers#python` snippet implies `HTTPFacilitatorClient(FacilitatorConfig(url=...))` auto-picks up `CDP_API_KEY_ID`/`SECRET` from env — empirically it does not (returns 401 Unauthorized). ## What this PR does - New `coinbase` install extra → pulls in `cdp-sdk>=1.0,<2`. - `create_x402_server(facilitator="coinbase")` now builds an `HTTPFacilitatorClient` pointed at `https://api.cdp.coinbase.com/platform/v2/x402` with a `CreateHeadersAuthProvider` that mints per-endpoint Bearer JWTs via `cdp.auth.utils.jwt.generate_jwt`. Mirrors the `@coinbase/x402` (TS) shape. - Reads `CDP_API_KEY_ID` / `CDP_API_KEY_SECRET` from env, or accepts explicit `cdp_api_key_id` / `cdp_api_key_secret` args. Raises a clear `ValueError` when missing; raises a guiding `ImportError` when `cdp-sdk` isn't installed. - Also fixes the `http` branch to use `HTTPFacilitatorClient` (was also passing a bare `x402Facilitator()`), so the public `x402.org` testnet facilitator actually populates `_supported_responses`. - Updates doc strings to call out that `x402[evm]>=2.9` is the required peer. ## Verification (live, against prod CDP) ``` $ CDP_API_KEY_ID=... CDP_API_KEY_SECRET=... uv run python -c "..." init OK _supported_responses: ['eip155:1', 'polygon', 'arbitrum', 'world', ..., 'eip155:8453', ...] eip155:8453: ['exact', 'upto'] build OK, requirements len: 1 asset: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 amount: 100000 # $0.10 USDC at 6 decimals ``` ## Tests - New: `test_create_x402_server_coinbase_facilitator_wires_cdp_jwt` (asserts HTTPFacilitatorClient at the CDP URL with auth_provider attached) - New: `test_create_x402_server_coinbase_without_creds_raises` (asserts the missing-creds ValueError) - Full suite: **719 passed, 3 skipped, 95.33% coverage** (above 95% bar). ## Doc updates - `README.md` + `CLAUDE.md`: add `[coinbase]` extra install snippet + CDP env var note. - `core/docs/integrations/python-commerce.mdx` (Mintlify): same. - `examples/variable_cost_merchant.py`: corrects stale `server.process_payment_request(request)` reference (replaced by `process_x402_settle(...)` in 1.3.1) and adds `[coinbase]` to the peer-dep install line. ## Test plan - [ ] CI green. - [ ] Tag `v1.3.2`, push tag → trigger PyPI publish via trusted publisher. - [ ] Bump `core/store/uv.lock` to 1.3.2 + add `[coinbase]` extra to `core/store/pyproject.toml`. - [ ] Deploy store prod. - [ ] Re-run T4 base mainnet smoke against agents.agentscore.sh — expect a **200 + order**, not a 503 SchemeNotFoundError. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0acbe39 commit dcb8041

7 files changed

Lines changed: 417 additions & 22 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Every helper is extracted from a real consumer, not speculated.
99
| Submodule | What it is |
1010
|---|---|
1111
| `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware (KYC, age, sanctions, jurisdiction) |
12-
| `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 |
12+
| `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 |
1313
| `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 |
1414
| `agentscore_commerce.challenge` | 402-body builders: accepted_methods, identity_metadata, how_to_pay, agent_instructions, build_402_body, `build_validation_error` (4xx body builder) |
1515
| `agentscore_commerce.stripe_multichain` | Multichain PaymentIntent helper, deposit-address lookup, testnet simulator, mppx Stripe wrapper |
@@ -30,7 +30,7 @@ Single Python package, hatchling-built, published to PyPI as `agentscore-commerc
3030
| `examples/` | Runnable single-file FastAPI apps for each common scenario |
3131
| `tests/` | pytest, one file per surface |
3232

33-
Peer-dep pattern: payment/x402/mppx/stripe modules import lazily at runtime — vendors install only what they use via extras (`pip install agentscore-commerce[fastapi,stripe]` etc.). Underlying packages: `x402[evm]`, `pympp[server,tempo,stripe]`, `stripe`. Missing peer dep raises a guiding `ImportError` with the install command.
33+
Peer-dep pattern: payment/x402/mppx/stripe modules import lazily at runtime — vendors install only what they use via extras (`pip install agentscore-commerce[fastapi,stripe]` etc.). Underlying packages: `x402[evm]`, `pympp[server,tempo,stripe]`, `stripe`, `cdp-sdk` (the `coinbase` extra — only needed when `facilitator="coinbase"`). Missing peer dep raises a guiding `ImportError` with the install command.
3434

3535
## Examples
3636

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ The full merchant-side SDK for [AgentScore](https://agentscore.sh) in Python —
1111
pip install agentscore-commerce[fastapi] # or [flask], [django], [aiohttp], [sanic], [stripe]
1212
```
1313

14+
For x402 + Coinbase facilitator support (mints per-endpoint CDP JWTs via `cdp-sdk`):
15+
16+
```bash
17+
pip install 'agentscore-commerce[fastapi,x402,coinbase]'
18+
# Set CDP_API_KEY_ID and CDP_API_KEY_SECRET in the environment.
19+
```
20+
21+
`[mppx]` adds Tempo MPP + Stripe SPT helpers via `pympp[server,tempo,stripe]`.
22+
1423
## What's in the package
1524

1625
| Submodule | What it provides |

agentscore_commerce/payment/x402_server.py

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
1616
`x402` is an OPTIONAL peer dependency — install only the schemes you use::
1717
18-
pip install 'x402[evm,fastapi]>=2.8,<3' # plus 'coinbase-x402' for the Coinbase facilitator
18+
pip install 'x402[evm,fastapi]>=2.9,<3' # for non-Coinbase facilitators
19+
pip install 'agentscore-commerce[x402,coinbase]' # for the Coinbase facilitator (adds cdp-sdk)
1920
"""
2021

2122
from __future__ import annotations
2223

2324
import importlib
25+
import os
2426
from dataclasses import dataclass, field
2527
from typing import TYPE_CHECKING, Any, Literal
2628

@@ -29,6 +31,8 @@
2931
if TYPE_CHECKING:
3032
from collections.abc import Iterable
3133

34+
COINBASE_FACILITATOR_URL = "https://api.cdp.coinbase.com/platform/v2/x402"
35+
3236
X402SymbolicRail = Literal[
3337
"x402-base-mainnet",
3438
"x402-base-sepolia",
@@ -52,8 +56,10 @@ class CreateX402ServerOptions:
5256
"""Configuration for :func:`create_x402_server`."""
5357

5458
facilitator: X402FacilitatorChoice | Any = "http"
55-
"""Facilitator selection — ``"coinbase"`` (requires ``coinbase-x402``), ``"http"``
56-
(public testnet facilitator), or any pre-built facilitator instance."""
59+
"""Facilitator selection — ``"coinbase"`` (requires ``cdp-sdk`` peer dep + the
60+
``CDP_API_KEY_ID`` / ``CDP_API_KEY_SECRET`` env vars or explicit ``cdp_api_key_id``
61+
/ ``cdp_api_key_secret`` args), ``"http"`` (public testnet facilitator at
62+
``x402.org``), or any pre-built facilitator instance."""
5763

5864
rails: list[X402SymbolicRail] = field(default_factory=list)
5965
"""Symbolic rail names to register schemes for. Each gets v1+v2 dual-register
@@ -68,6 +74,14 @@ class CreateX402ServerOptions:
6874
initialize: bool = True
6975
"""Initialize the server immediately (calls facilitator). Default ``True``."""
7076

77+
cdp_api_key_id: str | None = None
78+
"""CDP API key id for the Coinbase facilitator. Falls back to
79+
``CDP_API_KEY_ID`` env var. Only consulted when ``facilitator="coinbase"``."""
80+
81+
cdp_api_key_secret: str | None = None
82+
"""CDP API key secret for the Coinbase facilitator. Falls back to
83+
``CDP_API_KEY_SECRET`` env var. Only consulted when ``facilitator="coinbase"``."""
84+
7185

7286
def _import_optional(module_name: str) -> Any | None:
7387
"""Try to import a module; return ``None`` if not installed."""
@@ -77,12 +91,84 @@ def _import_optional(module_name: str) -> Any | None:
7791
return None
7892

7993

94+
def _build_coinbase_facilitator(
95+
x402_top: Any,
96+
api_key_id: str | None,
97+
api_key_secret: str | None,
98+
) -> Any:
99+
"""Build a ``HTTPFacilitatorClient`` pointed at the Coinbase facilitator with CDP JWT auth.
100+
101+
Uses ``cdp-sdk``'s ``generate_jwt`` to mint a per-endpoint Bearer token (CDP rotates
102+
JWTs every 120s by default). Mirrors the TS ``@coinbase/x402`` package's
103+
``createCdpAuthHeaders`` shape exactly so the verify / settle / supported routes
104+
each get a JWT scoped to their HTTP method + path.
105+
"""
106+
api_key_id = api_key_id or os.environ.get("CDP_API_KEY_ID")
107+
api_key_secret = api_key_secret or os.environ.get("CDP_API_KEY_SECRET")
108+
if not api_key_id or not api_key_secret:
109+
msg = (
110+
"facilitator='coinbase' requires CDP_API_KEY_ID and CDP_API_KEY_SECRET — "
111+
"set them as env vars or pass cdp_api_key_id / cdp_api_key_secret to "
112+
"create_x402_server."
113+
)
114+
raise ValueError(msg)
115+
116+
cdp_jwt_module = _import_optional("cdp.auth.utils.jwt")
117+
if cdp_jwt_module is None:
118+
msg = (
119+
"cdp-sdk not installed — run `pip install 'agentscore-commerce[coinbase]'` "
120+
"(or `pip install cdp-sdk`) to use facilitator='coinbase'."
121+
)
122+
raise ImportError(msg)
123+
124+
http_module = _import_optional("x402.http")
125+
facilitator_config_cls = getattr(http_module, "FacilitatorConfig", None) if http_module else None
126+
facilitator_client_cls = getattr(http_module, "HTTPFacilitatorClient", None) if http_module else None
127+
if facilitator_config_cls is None or facilitator_client_cls is None:
128+
msg = "x402.http missing FacilitatorConfig / HTTPFacilitatorClient — upgrade x402>=2.9."
129+
raise ImportError(msg)
130+
131+
facilitator_url = COINBASE_FACILITATOR_URL
132+
request_host = facilitator_url.split("://", 1)[1].split("/", 1)[0]
133+
request_path = "/" + facilitator_url.split("://", 1)[1].split("/", 1)[1]
134+
jwt_options_cls = cdp_jwt_module.JwtOptions
135+
generate_jwt = cdp_jwt_module.generate_jwt
136+
137+
def _mint_bearer(method: str, path: str) -> str:
138+
token = generate_jwt(
139+
jwt_options_cls(
140+
api_key_id=api_key_id,
141+
api_key_secret=api_key_secret,
142+
request_method=method,
143+
request_host=request_host,
144+
request_path=path,
145+
)
146+
)
147+
return f"Bearer {token}"
148+
149+
def _create_headers() -> dict[str, dict[str, str]]:
150+
return {
151+
"verify": {"Authorization": _mint_bearer("POST", f"{request_path}/verify")},
152+
"settle": {"Authorization": _mint_bearer("POST", f"{request_path}/settle")},
153+
"supported": {"Authorization": _mint_bearer("GET", f"{request_path}/supported")},
154+
}
155+
156+
create_headers_provider_cls = getattr(http_module, "CreateHeadersAuthProvider", None)
157+
config = facilitator_config_cls(
158+
url=facilitator_url,
159+
auth_provider=create_headers_provider_cls(_create_headers) if create_headers_provider_cls else None,
160+
)
161+
return facilitator_client_cls(config)
162+
163+
80164
async def create_x402_server(
81165
facilitator: X402FacilitatorChoice | Any = "http",
82166
rails: Iterable[X402SymbolicRail] | None = None,
83167
schemes: Iterable[CustomScheme] | None = None,
84168
bazaar: bool = False,
85169
initialize: bool = True,
170+
cdp_api_key_id: str | None = None,
171+
cdp_api_key_secret: str | None = None,
86172
) -> Any:
87173
"""One-call x402 server setup.
88174
@@ -107,13 +193,20 @@ async def create_x402_server(
107193

108194
facilitator_instance: Any
109195
if facilitator == "coinbase":
110-
# x402 2.9's x402Facilitator() takes no constructor args. Coinbase
111-
# facilitator selection happens via FacilitatorConfig at construction
112-
# time — for the Coinbase preset, use facilitator="http" with hooks
113-
# or pass a pre-built instance via facilitator=<your_facilitator>.
114-
facilitator_instance = x402_top.x402Facilitator()
196+
# Coinbase's x402 facilitator at api.cdp.coinbase.com requires a JWT
197+
# bearer per endpoint signed with the CDP API key. A bare x402Facilitator()
198+
# does NOT auto-pick up CDP creds — the public docs implying otherwise
199+
# are wrong. Build an HTTPFacilitatorClient with a CreateHeadersAuthProvider
200+
# that mints per-endpoint JWTs via cdp-sdk.
201+
facilitator_instance = _build_coinbase_facilitator(x402_top, cdp_api_key_id, cdp_api_key_secret)
115202
elif facilitator == "http":
116-
facilitator_instance = x402_top.x402Facilitator()
203+
# Public x402.org testnet facilitator. HTTPFacilitatorClient with no auth.
204+
http_module = _import_optional("x402.http")
205+
facilitator_client_cls = getattr(http_module, "HTTPFacilitatorClient", None) if http_module else None
206+
if facilitator_client_cls is None:
207+
msg = "x402.http missing HTTPFacilitatorClient — upgrade x402>=2.9."
208+
raise ImportError(msg)
209+
facilitator_instance = facilitator_client_cls()
117210
else:
118211
# Pre-built facilitator instance passed directly.
119212
facilitator_instance = facilitator

examples/variable_cost_merchant.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@
2121
Python rail-verification peer deps are now wrapped — see `create_x402_server` and
2222
`create_mppx_server` in `agentscore_commerce.payment` for one-call setup. This example
2323
keeps the response shape direct (helper-composed) so the variable-cost flow is readable;
24-
in production wire `create_x402_server(rails=["x402-base-mainnet-upto"])` and call
25-
`server.process_payment_request(request)` for verification + settlement.
24+
in production wire `create_x402_server(rails=["x402-base-mainnet-upto"], facilitator="coinbase")`
25+
and call `process_x402_settle(...)` for verification + settlement.
2626
2727
Peer deps:
28-
pip install agentscore-commerce[fastapi,x402,mppx]
28+
pip install agentscore-commerce[fastapi,x402,mppx,coinbase]
2929
3030
Env vars:
3131
X402_BASE_RECIPIENT — your Base wallet (USDC payouts for upto rail)

pyproject.toml

Lines changed: 8 additions & 5 deletions
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 = "1.3.1"
7+
version = "1.3.2"
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"
@@ -36,11 +36,13 @@ sanic = ["sanic>=23.0.0"]
3636
stripe = ["stripe>=11.0.0"]
3737
# Payment-protocol peer deps for create_x402_server / create_mppx_server.
3838
# Installed only by merchants who accept the corresponding rail.
39-
# Note: the Coinbase x402 facilitator is reached through the main `x402`
40-
# package (HTTPFacilitatorClient pointed at the Coinbase URL), not a separate
41-
# package — no `coinbase-x402` extra needed.
42-
x402 = ["x402[evm,fastapi]>=2.8,<3"]
39+
# Note: there is no Python `coinbase-x402` package. The Coinbase facilitator is
40+
# reached through the main `x402` package's HTTPFacilitatorClient pointed at
41+
# https://api.cdp.coinbase.com/platform/v2/x402 with a CDP-JWT auth provider;
42+
# `cdp-sdk` is the JWT minter. Install via the `coinbase` extra when needed.
43+
x402 = ["x402[evm,fastapi]>=2.9,<3"]
4344
mppx = ["pympp[server,tempo,stripe]>=0.6,<1"]
45+
coinbase = ["cdp-sdk>=1.0,<2"]
4446

4547
[project.urls]
4648
Homepage = "https://agentscore.sh"
@@ -67,6 +69,7 @@ dev = [
6769
"python-dotenv>=1.2.2",
6870
"stripe>=11.0.0",
6971
"lefthook>=2.1.6",
72+
"cdp-sdk>=1.0,<2",
7073
]
7174

7275
[tool.ty.src]

tests/test_payment_servers.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,47 @@ async def test_create_x402_server_registers_base_sepolia_scheme() -> None:
5050
assert "exact" in server._schemes["eip155:84532"]
5151

5252

53+
@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed")
54+
@pytest.mark.asyncio
55+
async def test_create_x402_server_coinbase_facilitator_wires_cdp_jwt(monkeypatch: pytest.MonkeyPatch) -> None:
56+
"""``facilitator="coinbase"`` builds an HTTPFacilitatorClient pointed at the
57+
CDP URL with a per-endpoint JWT auth provider — not a bare in-process facilitator.
58+
59+
This is the regression that 1.3.2 fixes: 1.3.0 + 1.3.1 both passed an empty
60+
``x402Facilitator()`` instance and silently failed downstream when
61+
``build_payment_requirements`` looked up the supported map.
62+
"""
63+
cdp_sdk_installed = importlib.util.find_spec("cdp.auth.utils.jwt") is not None
64+
if not cdp_sdk_installed:
65+
pytest.skip("cdp-sdk not installed (install via the `coinbase` extra)")
66+
67+
monkeypatch.setenv("CDP_API_KEY_ID", "test-key-id")
68+
monkeypatch.setenv("CDP_API_KEY_SECRET", "test-key-secret")
69+
70+
server = await create_x402_server(
71+
facilitator="coinbase",
72+
rails=["x402-base-mainnet"],
73+
initialize=False,
74+
)
75+
facilitator_clients = server._facilitator_clients
76+
assert len(facilitator_clients) == 1
77+
facilitator = facilitator_clients[0]
78+
assert type(facilitator).__name__ == "HTTPFacilitatorClient"
79+
assert facilitator.url == "https://api.cdp.coinbase.com/platform/v2/x402"
80+
assert facilitator._auth_provider is not None
81+
82+
83+
@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed")
84+
@pytest.mark.asyncio
85+
async def test_create_x402_server_coinbase_without_creds_raises(monkeypatch: pytest.MonkeyPatch) -> None:
86+
"""``facilitator="coinbase"`` with no CDP creds raises a clear ValueError."""
87+
monkeypatch.delenv("CDP_API_KEY_ID", raising=False)
88+
monkeypatch.delenv("CDP_API_KEY_SECRET", raising=False)
89+
90+
with pytest.raises(ValueError, match="CDP_API_KEY_ID and CDP_API_KEY_SECRET"):
91+
await create_x402_server(facilitator="coinbase", rails=["x402-base-mainnet"], initialize=False)
92+
93+
5394
@pytest.mark.skipif(not _MPPX_INSTALLED or not _TEMPO_INSTALLED, reason="pympp[tempo] not installed")
5495
@pytest.mark.asyncio
5596
async def test_create_mppx_server_tempo_returns_mpp_instance() -> None:

0 commit comments

Comments
 (0)