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.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 |
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion agentscore_commerce/discovery/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions agentscore_commerce/payment/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
CustomScheme,
X402FacilitatorChoice,
X402SymbolicRail,
build_x402_accepts_for_402,
create_x402_server,
)
from agentscore_commerce.payment.x402_settle import (
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions agentscore_commerce/payment/x402_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
12 changes: 6 additions & 6 deletions agentscore_commerce/payment/x402_settle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
12 changes: 10 additions & 2 deletions examples/api_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
28 changes: 11 additions & 17 deletions examples/multi_rail_merchant.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
ProcessX402SettleInput,
ValidateX402NetworkConfigInput,
VerifyX402RequestInput,
build_x402_accepts_for_402,
networks,
process_x402_settle,
validate_x402_network_config,
Expand Down Expand Up @@ -250,24 +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.
"extra": {"name": "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,
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.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"
Expand Down
6 changes: 5 additions & 1 deletion tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions tests/test_payment_servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
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