Skip to content

Commit c13bb38

Browse files
vvillait88claude
andauthored
feat(x402): public helpers for 402-emit + probe extra.name fix (1.3.4) (#11)
## Summary Cleanup PR after the verify→settle fix chain (1.3.1 / 1.3.2 / 1.3.3). Two parallel goals: 1. **Lift up the boilerplate every Python merchant inlined for emitting x402 challenges** so the `extra.name` mistake (which silently breaks every signature at the facilitator on base mainnet) can't recur. 2. **Fix the discovery probe + examples + tests** that hardcoded `extra: {"name": "USDC"}` regardless of network (correct on sepolia, wrong on mainnet — the actual USDC contract on Base mainnet returns `name() = "USD Coin"`, sepolia returns `"USDC"`). ## New public API ```python from agentscore_commerce.payment import build_x402_accepts_for_402 x402_accepts = build_x402_accepts_for_402( x402_server, network="eip155:8453", 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[]`. The helper: - Builds a typed `ResourceConfig` from kwargs (so callers don't import the x402 type) - Calls `server.build_payment_requirements(...)` which fills in `extra` from the registered scheme metadata (sepolia gets `"USDC"`, mainnet gets `"USD Coin"`, future networks get whatever the registered scheme says) - Dumps each Pydantic `PaymentRequirements` to wire-shape via `model_dump(by_alias=True, mode="json")` - Falls through for dict-shaped requirements (older x402 / test stubs) Also promoted from private (no behavior change; just public stability): - `coerce_resource_config` (dict → `ResourceConfig`) - `coerce_payment_payload` (dict → `PaymentPayload` / `PaymentPayloadV1` per `x402Version`) - `settle_result_to_json_bytes` (Pydantic `SettleResponse` → JSON bytes via `model_dump_json(by_alias=True)`; falls through to `json.dumps` for plain dicts) ## Probe / examples / tests - `discovery/probe.py`: `sample_x402_accept_for_network` now returns: - `eip155:8453` → `extra: {"name": "USD Coin", "version": "2"}` (was `"USDC"`, wrong) - `eip155:84532` → `extra: {"name": "USDC", "version": "2"}` (matches the sepolia contract) - `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; added explicit sepolia assertion. ## Tests - New: `test_build_x402_accepts_for_402_returns_dicts_from_typed_requirements` (passes typed `ResourceConfig` to the server, returns wire-shape dicts with `payTo`/`maxTimeoutSeconds`/`extra` camelCase) - Full suite: 724 passed, 3 skipped, **95.16% coverage**. ## Doc updates - `README.md` + `CLAUDE.md` + `core/docs/integrations/python-commerce.mdx` all gain a `build_x402_accepts_for_402` snippet. ## Test plan - [ ] CI green. - [ ] Tag `v1.3.4`, push tag → PyPI publish. - [ ] Bump `core/store/uv.lock` to 1.3.4 + refactor `store/routes/purchase.py` to use the new helper (folded into existing core PR #237). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 69eaddb commit c13bb38

12 files changed

Lines changed: 179 additions & 30 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
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.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 |
12+
| `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 |
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 |

‎README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,22 @@ await simulate_deposit_if_test_mode(SimulateDepositIfTestModeInput(
247247
))
248248
```
249249

250+
## Build the x402 accepts entry for the 402 challenge
251+
252+
```python
253+
from agentscore_commerce.payment import build_x402_accepts_for_402
254+
255+
x402_accepts = build_x402_accepts_for_402(
256+
x402_server,
257+
network=X402_BASE,
258+
price=f"${total_usd}",
259+
pay_to=os.environ["TREASURY_BASE_RECIPIENT"],
260+
max_timeout_seconds=300,
261+
)
262+
```
263+
264+
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.
265+
250266
## Drop-in 402 + settle (x402)
251267

252268
```python

‎agentscore_commerce/discovery/probe.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ def sample_x402_accept_for_network(caip2: str, amount_atomic: str = "1000000") -
3939
"asset": USDC.base.mainnet.address,
4040
"payTo": _ZERO_EVM_PAYTO,
4141
"maxTimeoutSeconds": 300,
42-
"extra": {"name": "USDC", "version": "2"},
42+
# ``extra.name`` mirrors the on-chain USDC contract's ``name()`` return value
43+
# because EIP-712 domain hashes include this string. Wrong name → every
44+
# signed payload fails facilitator verify with ``invalid_exact_evm_payload_signature``.
45+
# Base mainnet USDC returns "USD Coin"; base sepolia USDC returns "USDC".
46+
"extra": {"name": "USD Coin", "version": "2"},
4347
}
4448
if caip2 == networks.base.sepolia.caip2:
4549
return {

‎agentscore_commerce/payment/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
CustomScheme,
5353
X402FacilitatorChoice,
5454
X402SymbolicRail,
55+
build_x402_accepts_for_402,
5556
create_x402_server,
5657
)
5758
from agentscore_commerce.payment.x402_settle import (
@@ -61,7 +62,10 @@
6162
ProcessX402SettleResult,
6263
ProcessX402SettleSuccess,
6364
classify_x402_settle_result,
65+
coerce_payment_payload,
66+
coerce_resource_config,
6467
process_x402_settle,
68+
settle_result_to_json_bytes,
6569
)
6670
from agentscore_commerce.payment.x402_validation import (
6771
X402_SUPPORTED_BASE_NETWORKS,
@@ -114,7 +118,10 @@
114118
"build_payment_directive",
115119
"build_payment_headers",
116120
"build_payment_request_blob",
121+
"build_x402_accepts_for_402",
117122
"classify_x402_settle_result",
123+
"coerce_payment_payload",
124+
"coerce_resource_config",
118125
"create_mppx_server",
119126
"create_x402_server",
120127
"dispatch_settlement_by_network",
@@ -130,6 +137,7 @@
130137
"rails",
131138
"read_x402_payment_header",
132139
"register_x402_schemes_v1_v2",
140+
"settle_result_to_json_bytes",
133141
"settlement_override_header",
134142
"validate_x402_network_config",
135143
"verify_x402_request",

‎agentscore_commerce/payment/x402_server.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,10 +266,75 @@ async def create_x402_server(
266266
return server
267267

268268

269+
def build_x402_accepts_for_402(
270+
server: Any,
271+
*,
272+
network: str,
273+
price: str,
274+
pay_to: str,
275+
scheme: str = "exact",
276+
max_timeout_seconds: int = 300,
277+
extensions: list[str] | None = None,
278+
) -> list[dict[str, Any]]:
279+
"""Build x402 ``accepts[]`` entries for a 402 challenge body.
280+
281+
Wraps ``server.build_payment_requirements(...)`` so merchants don't have to:
282+
283+
1. Import ``x402.schemas.config.ResourceConfig`` themselves
284+
2. Remember to call ``model_dump(by_alias=True, mode="json")`` on each Pydantic
285+
requirement so the surrounding JSON response can serialize it
286+
3. Hardcode ``extra`` (which differs by the actual on-chain contract: base mainnet
287+
USDC has ``name="USD Coin"``, base sepolia USDC has ``name="USDC"`` — EIP-712
288+
domain hashes differ, so getting this wrong silently breaks every signature
289+
verify at the facilitator)
290+
291+
Returns a list of plain dicts in the shape that x402 expects on the wire — drop
292+
them straight into the ``accepts`` field of the 402 challenge body.
293+
294+
Raises ``Exception`` if the underlying ``build_payment_requirements`` raises;
295+
callers should wrap with ``try/except`` and either omit x402 from the 402 or
296+
surface a 5xx (depending on whether other rails are advertised).
297+
"""
298+
config_cls_module = _import_optional("x402.schemas.config")
299+
config_cls = getattr(config_cls_module, "ResourceConfig", None) if config_cls_module else None
300+
if config_cls is None:
301+
msg = "x402 not installed — run `pip install 'x402[evm,fastapi]>=2.9,<3'` to use build_x402_accepts_for_402."
302+
raise ImportError(msg)
303+
config = config_cls(
304+
scheme=scheme,
305+
network=network,
306+
price=price,
307+
pay_to=pay_to,
308+
max_timeout_seconds=max_timeout_seconds,
309+
)
310+
requirements = (
311+
server.build_payment_requirements(config, extensions)
312+
if extensions
313+
else server.build_payment_requirements(config)
314+
)
315+
# Pydantic ``PaymentRequirements`` is the live shape under x402 2.9+. Older
316+
# versions (and test stubs) return plain dicts that already match the wire form.
317+
out: list[dict[str, Any]] = []
318+
for req in requirements:
319+
model_dump = getattr(req, "model_dump", None)
320+
if callable(model_dump):
321+
out.append(model_dump(by_alias=True, mode="json"))
322+
elif isinstance(req, dict):
323+
out.append(dict(req))
324+
else:
325+
msg = (
326+
f"build_payment_requirements returned {type(req).__name__}; expected a "
327+
"Pydantic PaymentRequirements or a dict."
328+
)
329+
raise TypeError(msg)
330+
return out
331+
332+
269333
__all__ = [
270334
"CreateX402ServerOptions",
271335
"CustomScheme",
272336
"X402FacilitatorChoice",
273337
"X402SymbolicRail",
338+
"build_x402_accepts_for_402",
274339
"create_x402_server",
275340
]

‎agentscore_commerce/payment/x402_settle.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def classify_x402_settle_result(result: ProcessX402SettleResult) -> ClassifiedX4
189189
return None
190190

191191

192-
def _coerce_resource_config(config: Any) -> Any:
192+
def coerce_resource_config(config: Any) -> Any:
193193
"""Best-effort dict → x402 ``ResourceConfig`` coercion.
194194
195195
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:
220220
return config
221221

222222

223-
def _coerce_payment_payload(payload: Any) -> Any:
223+
def coerce_payment_payload(payload: Any) -> Any:
224224
"""Best-effort dict → x402 ``PaymentPayload`` (v1 or v2) coercion.
225225
226226
``verify_x402_request`` returns ``payload`` as a plain dict (the result of
@@ -252,8 +252,8 @@ def _coerce_payment_payload(payload: Any) -> Any:
252252
async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402SettleResult:
253253
"""Run the x402 verify→settle flow and return a tagged outcome."""
254254
server = input.x402_server
255-
resource_config = _coerce_resource_config(input.resource_config)
256-
payload = _coerce_payment_payload(input.payload)
255+
resource_config = coerce_resource_config(input.resource_config)
256+
payload = coerce_payment_payload(input.payload)
257257

258258
try:
259259
built_requirements = server.build_payment_requirements(resource_config)
@@ -321,7 +321,7 @@ async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402Settl
321321
settle_result = await server.settle_payment(payload, matched_requirement)
322322
payment_response_header: str | None = None
323323
if settle_result is not None:
324-
payment_response_header = base64.b64encode(_settle_result_to_json_bytes(settle_result)).decode()
324+
payment_response_header = base64.b64encode(settle_result_to_json_bytes(settle_result)).decode()
325325
return ProcessX402SettleSuccess(
326326
matched_requirement=matched_requirement,
327327
settle_result=settle_result,
@@ -332,7 +332,7 @@ async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402Settl
332332
return ProcessX402SettleFailure(phase="settle_failed", error=err, matched_requirement=matched_requirement)
333333

334334

335-
def _settle_result_to_json_bytes(settle_result: Any) -> bytes:
335+
def settle_result_to_json_bytes(settle_result: Any) -> bytes:
336336
"""Serialize the settle result to a base64-friendly JSON byte string.
337337
338338
x402 2.9's ``settle_payment`` returns a Pydantic ``SettleResponse`` model that

‎examples/api_provider.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,16 @@ async def search(request: Request):
128128
"payTo": os.environ["X402_BASE_RECIPIENT"],
129129
"maxTimeoutSeconds": 300,
130130
# EIP-712 domain required by every x402 EVM client to sign
131-
# EIP-3009 TransferWithAuthorization.
132-
"extra": {"name": "USDC", "version": "2"},
131+
# EIP-3009 TransferWithAuthorization. ``name`` MUST match the
132+
# on-chain USDC contract's ``name()`` — base mainnet returns
133+
# "USD Coin", base sepolia returns "USDC". Wrong value silently
134+
# breaks signature verify at the facilitator. Production code
135+
# should use ``build_x402_accepts_for_402(server, ...)`` which
136+
# derives ``extra`` from the registered scheme metadata.
137+
"extra": {
138+
"name": "USD Coin" if X402_BASE_NETWORK.split(":")[-1] == "8453" else "USDC",
139+
"version": "2",
140+
},
133141
},
134142
]
135143
return JSONResponse(

‎examples/multi_rail_merchant.py‎

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
ProcessX402SettleInput,
7070
ValidateX402NetworkConfigInput,
7171
VerifyX402RequestInput,
72+
build_x402_accepts_for_402,
7273
networks,
7374
process_x402_settle,
7475
validate_x402_network_config,
@@ -250,24 +251,17 @@ async def purchase(request: Request, assess: dict = Depends(get_assess_data)):
250251
),
251252
x402=PaymentRequiredHeaderInput(
252253
x402_version=2,
254+
# Base accept comes from the registered x402 scheme — `extra` (incl. the
255+
# network-correct USDC `name`) is filled in automatically. Solana goes
256+
# through MPP `solana/charge` not x402's exact scheme, so it stays inline.
253257
accepts=[
254-
{
255-
"scheme": "exact",
256-
"network": X402_BASE_NETWORK,
257-
"amount": str(round(float(total_usd) * 1_000_000)),
258-
# Asset must match the configured network — sepolia + mainnet
259-
# use different USDC contracts.
260-
"asset": (
261-
USDC.base.sepolia.address
262-
if networks.base.sepolia.caip2 == X402_BASE_NETWORK
263-
else USDC.base.mainnet.address
264-
),
265-
"payTo": deposit_addresses["base"],
266-
"maxTimeoutSeconds": 300,
267-
# EIP-712 domain — required by every x402 EVM client to
268-
# sign EIP-3009 TransferWithAuthorization.
269-
"extra": {"name": "USDC", "version": "2"},
270-
},
258+
*build_x402_accepts_for_402(
259+
x402_server,
260+
network=X402_BASE_NETWORK,
261+
price=f"${total_usd}",
262+
pay_to=deposit_addresses["base"],
263+
max_timeout_seconds=300,
264+
),
271265
{
272266
"scheme": "exact",
273267
"network": SOLANA_NETWORK_CAIP2,

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
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.3"
7+
version = "1.3.4"
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"

‎tests/test_discovery.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,9 @@ def test_sample_accept_base_mainnet() -> None:
176176
assert e is not None
177177
assert e["network"] == "eip155:8453"
178178
assert e["scheme"] == "exact"
179-
assert e["extra"] == {"name": "USDC", "version": "2"}
179+
# Base mainnet USDC's contract returns ``name() == "USD Coin"``. ``extra.name``
180+
# mirrors that for EIP-712 domain-hash parity with what the facilitator validates.
181+
assert e["extra"] == {"name": "USD Coin", "version": "2"}
180182

181183

182184
def test_sample_accept_base_sepolia() -> None:
@@ -185,6 +187,8 @@ def test_sample_accept_base_sepolia() -> None:
185187
e = sample_x402_accept_for_network("eip155:84532")
186188
assert e is not None
187189
assert e["network"] == "eip155:84532"
190+
# Base sepolia USDC contract returns ``name() == "USDC"`` (differs from mainnet).
191+
assert e["extra"] == {"name": "USDC", "version": "2"}
188192

189193

190194
def test_sample_accept_solana_mainnet() -> None:

0 commit comments

Comments
 (0)