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
4 changes: 2 additions & 2 deletions 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), `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 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.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 All @@ -30,7 +30,7 @@ Single Python package, hatchling-built, published to PyPI as `agentscore-commerc
| `examples/` | Runnable single-file FastAPI apps for each common scenario |
| `tests/` | pytest, one file per surface |

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.
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.

## Examples

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ The full merchant-side SDK for [AgentScore](https://agentscore.sh) in Python —
pip install agentscore-commerce[fastapi] # or [flask], [django], [aiohttp], [sanic], [stripe]
```

For x402 + Coinbase facilitator support (mints per-endpoint CDP JWTs via `cdp-sdk`):

```bash
pip install 'agentscore-commerce[fastapi,x402,coinbase]'
# Set CDP_API_KEY_ID and CDP_API_KEY_SECRET in the environment.
```

`[mppx]` adds Tempo MPP + Stripe SPT helpers via `pympp[server,tempo,stripe]`.

## What's in the package

| Submodule | What it provides |
Expand Down
111 changes: 102 additions & 9 deletions agentscore_commerce/payment/x402_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@

`x402` is an OPTIONAL peer dependency — install only the schemes you use::

pip install 'x402[evm,fastapi]>=2.8,<3' # plus 'coinbase-x402' for the Coinbase facilitator
pip install 'x402[evm,fastapi]>=2.9,<3' # for non-Coinbase facilitators
pip install 'agentscore-commerce[x402,coinbase]' # for the Coinbase facilitator (adds cdp-sdk)
"""

from __future__ import annotations

import importlib
import os
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal

Expand All @@ -29,6 +31,8 @@
if TYPE_CHECKING:
from collections.abc import Iterable

COINBASE_FACILITATOR_URL = "https://api.cdp.coinbase.com/platform/v2/x402"

X402SymbolicRail = Literal[
"x402-base-mainnet",
"x402-base-sepolia",
Expand All @@ -52,8 +56,10 @@ class CreateX402ServerOptions:
"""Configuration for :func:`create_x402_server`."""

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

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

cdp_api_key_id: str | None = None
"""CDP API key id for the Coinbase facilitator. Falls back to
``CDP_API_KEY_ID`` env var. Only consulted when ``facilitator="coinbase"``."""

cdp_api_key_secret: str | None = None
"""CDP API key secret for the Coinbase facilitator. Falls back to
``CDP_API_KEY_SECRET`` env var. Only consulted when ``facilitator="coinbase"``."""


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


def _build_coinbase_facilitator(
x402_top: Any,
api_key_id: str | None,
api_key_secret: str | None,
) -> Any:
"""Build a ``HTTPFacilitatorClient`` pointed at the Coinbase facilitator with CDP JWT auth.

Uses ``cdp-sdk``'s ``generate_jwt`` to mint a per-endpoint Bearer token (CDP rotates
JWTs every 120s by default). Mirrors the TS ``@coinbase/x402`` package's
``createCdpAuthHeaders`` shape exactly so the verify / settle / supported routes
each get a JWT scoped to their HTTP method + path.
"""
api_key_id = api_key_id or os.environ.get("CDP_API_KEY_ID")
api_key_secret = api_key_secret or os.environ.get("CDP_API_KEY_SECRET")
if not api_key_id or not api_key_secret:
msg = (
"facilitator='coinbase' requires CDP_API_KEY_ID and CDP_API_KEY_SECRET — "
"set them as env vars or pass cdp_api_key_id / cdp_api_key_secret to "
"create_x402_server."
)
raise ValueError(msg)

cdp_jwt_module = _import_optional("cdp.auth.utils.jwt")
if cdp_jwt_module is None:
msg = (
"cdp-sdk not installed — run `pip install 'agentscore-commerce[coinbase]'` "
"(or `pip install cdp-sdk`) to use facilitator='coinbase'."
)
raise ImportError(msg)

http_module = _import_optional("x402.http")
facilitator_config_cls = getattr(http_module, "FacilitatorConfig", None) if http_module else None
facilitator_client_cls = getattr(http_module, "HTTPFacilitatorClient", None) if http_module else None
if facilitator_config_cls is None or facilitator_client_cls is None:
msg = "x402.http missing FacilitatorConfig / HTTPFacilitatorClient — upgrade x402>=2.9."
raise ImportError(msg)

facilitator_url = COINBASE_FACILITATOR_URL
request_host = facilitator_url.split("://", 1)[1].split("/", 1)[0]
request_path = "/" + facilitator_url.split("://", 1)[1].split("/", 1)[1]
jwt_options_cls = cdp_jwt_module.JwtOptions
generate_jwt = cdp_jwt_module.generate_jwt

def _mint_bearer(method: str, path: str) -> str:
token = generate_jwt(
jwt_options_cls(
api_key_id=api_key_id,
api_key_secret=api_key_secret,
request_method=method,
request_host=request_host,
request_path=path,
)
)
return f"Bearer {token}"

def _create_headers() -> dict[str, dict[str, str]]:
return {
"verify": {"Authorization": _mint_bearer("POST", f"{request_path}/verify")},
"settle": {"Authorization": _mint_bearer("POST", f"{request_path}/settle")},
"supported": {"Authorization": _mint_bearer("GET", f"{request_path}/supported")},
}

create_headers_provider_cls = getattr(http_module, "CreateHeadersAuthProvider", None)
config = facilitator_config_cls(
url=facilitator_url,
auth_provider=create_headers_provider_cls(_create_headers) if create_headers_provider_cls else None,
)
return facilitator_client_cls(config)


async def create_x402_server(
facilitator: X402FacilitatorChoice | Any = "http",
rails: Iterable[X402SymbolicRail] | None = None,
schemes: Iterable[CustomScheme] | None = None,
bazaar: bool = False,
initialize: bool = True,
cdp_api_key_id: str | None = None,
cdp_api_key_secret: str | None = None,
) -> Any:
"""One-call x402 server setup.

Expand All @@ -107,13 +193,20 @@ async def create_x402_server(

facilitator_instance: Any
if facilitator == "coinbase":
# x402 2.9's x402Facilitator() takes no constructor args. Coinbase
# facilitator selection happens via FacilitatorConfig at construction
# time — for the Coinbase preset, use facilitator="http" with hooks
# or pass a pre-built instance via facilitator=<your_facilitator>.
facilitator_instance = x402_top.x402Facilitator()
# Coinbase's x402 facilitator at api.cdp.coinbase.com requires a JWT
# bearer per endpoint signed with the CDP API key. A bare x402Facilitator()
# does NOT auto-pick up CDP creds — the public docs implying otherwise
# are wrong. Build an HTTPFacilitatorClient with a CreateHeadersAuthProvider
# that mints per-endpoint JWTs via cdp-sdk.
facilitator_instance = _build_coinbase_facilitator(x402_top, cdp_api_key_id, cdp_api_key_secret)
elif facilitator == "http":
facilitator_instance = x402_top.x402Facilitator()
# Public x402.org testnet facilitator. HTTPFacilitatorClient with no auth.
http_module = _import_optional("x402.http")
facilitator_client_cls = getattr(http_module, "HTTPFacilitatorClient", None) if http_module else None
if facilitator_client_cls is None:
msg = "x402.http missing HTTPFacilitatorClient — upgrade x402>=2.9."
raise ImportError(msg)
facilitator_instance = facilitator_client_cls()
else:
# Pre-built facilitator instance passed directly.
facilitator_instance = facilitator
Expand Down
6 changes: 3 additions & 3 deletions examples/variable_cost_merchant.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
Python rail-verification peer deps are now wrapped — see `create_x402_server` and
`create_mppx_server` in `agentscore_commerce.payment` for one-call setup. This example
keeps the response shape direct (helper-composed) so the variable-cost flow is readable;
in production wire `create_x402_server(rails=["x402-base-mainnet-upto"])` and call
`server.process_payment_request(request)` for verification + settlement.
in production wire `create_x402_server(rails=["x402-base-mainnet-upto"], facilitator="coinbase")`
and call `process_x402_settle(...)` for verification + settlement.

Peer deps:
pip install agentscore-commerce[fastapi,x402,mppx]
pip install agentscore-commerce[fastapi,x402,mppx,coinbase]

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

[project.urls]
Homepage = "https://agentscore.sh"
Expand All @@ -67,6 +69,7 @@ dev = [
"python-dotenv>=1.2.2",
"stripe>=11.0.0",
"lefthook>=2.1.6",
"cdp-sdk>=1.0,<2",
]

[tool.ty.src]
Expand Down
41 changes: 41 additions & 0 deletions tests/test_payment_servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,47 @@ async def test_create_x402_server_registers_base_sepolia_scheme() -> None:
assert "exact" in server._schemes["eip155:84532"]


@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed")
@pytest.mark.asyncio
async def test_create_x402_server_coinbase_facilitator_wires_cdp_jwt(monkeypatch: pytest.MonkeyPatch) -> None:
"""``facilitator="coinbase"`` builds an HTTPFacilitatorClient pointed at the
CDP URL with a per-endpoint JWT auth provider — not a bare in-process facilitator.

This is the regression that 1.3.2 fixes: 1.3.0 + 1.3.1 both passed an empty
``x402Facilitator()`` instance and silently failed downstream when
``build_payment_requirements`` looked up the supported map.
"""
cdp_sdk_installed = importlib.util.find_spec("cdp.auth.utils.jwt") is not None
if not cdp_sdk_installed:
pytest.skip("cdp-sdk not installed (install via the `coinbase` extra)")

monkeypatch.setenv("CDP_API_KEY_ID", "test-key-id")
monkeypatch.setenv("CDP_API_KEY_SECRET", "test-key-secret")

server = await create_x402_server(
facilitator="coinbase",
rails=["x402-base-mainnet"],
initialize=False,
)
facilitator_clients = server._facilitator_clients
assert len(facilitator_clients) == 1
facilitator = facilitator_clients[0]
assert type(facilitator).__name__ == "HTTPFacilitatorClient"
assert facilitator.url == "https://api.cdp.coinbase.com/platform/v2/x402"
assert facilitator._auth_provider is not None


@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed")
@pytest.mark.asyncio
async def test_create_x402_server_coinbase_without_creds_raises(monkeypatch: pytest.MonkeyPatch) -> None:
"""``facilitator="coinbase"`` with no CDP creds raises a clear ValueError."""
monkeypatch.delenv("CDP_API_KEY_ID", raising=False)
monkeypatch.delenv("CDP_API_KEY_SECRET", raising=False)

with pytest.raises(ValueError, match="CDP_API_KEY_ID and CDP_API_KEY_SECRET"):
await create_x402_server(facilitator="coinbase", rails=["x402-base-mainnet"], initialize=False)


@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:
Expand Down
Loading
Loading