Skip to content

fix(x402): wire Coinbase facilitator JWT auth (1.3.2)#9

Merged
vvillait88 merged 1 commit intomainfrom
fix/x402-cdp-facilitator-wiring
May 5, 2026
Merged

fix(x402): wire Coinbase facilitator JWT auth (1.3.2)#9
vvillait88 merged 1 commit intomainfrom
fix/x402-cdp-facilitator-wiring

Conversation

@vvillait88
Copy link
Copy Markdown
Contributor

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 (createCdpAuthHeadersgenerateJwt 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

…1.3.2)

Pre-existing bug unmasked by 1.3.1: facilitator='coinbase' was passing a
bare x402Facilitator() — an empty in-process facilitator with no schemes —
to x402ResourceServer. Worked through dict→ResourceConfig coercion in 1.3.1
but build_payment_requirements then raised SchemeNotFoundError("No scheme
'exact' registered for network 'eip155:8453'") because _supported_responses
was never populated.

The Coinbase x402 facilitator at api.cdp.coinbase.com requires a per-endpoint
JWT bearer signed with the CDP API secret over (method, host, path). The TS
sibling @coinbase/x402 ships that JWT minter; there is no Python equivalent
package. The docs.cdp.coinbase.com Python snippet implies HTTPFacilitatorClient
auto-picks up CDP_API_KEY_ID/SECRET — it does not (returns 401 Unauthorized).

Fix: use cdp-sdk's generate_jwt to mint per-endpoint Bearer tokens via the
existing CreateHeadersAuthProvider hook on HTTPFacilitatorClient. New
`coinbase` extra pulls in cdp-sdk; create_x402_server reads CDP creds from
env (or new cdp_api_key_id/secret args) and raises a guiding ValueError if
absent. Verified live against prod CDP — eip155:8453 listed under supported
with [exact, upto], $0.10 requirement built correctly.

Also fixes the http branch to use HTTPFacilitatorClient (not bare
x402Facilitator) so the public x402.org testnet facilitator actually
populates _supported_responses for testnet rails.

Tests:
- 6 new x402 server tests (3 already passing + 2 for coinbase wiring + 1 for missing-creds error path)
- Full suite: 719 passed, 95.33% coverage (above the 95% bar)

Doc updates: python-commerce README + CLAUDE.md + Mintlify python-commerce.mdx
+ variable_cost_merchant.py example. Removes stale process_payment_request
reference (replaced by process_x402_settle in 1.3.1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vvillait88 vvillait88 merged commit dcb8041 into main May 5, 2026
7 checks passed
@vvillait88 vvillait88 deleted the fix/x402-cdp-facilitator-wiring branch May 5, 2026 14:03
vvillait88 added a commit that referenced this pull request May 5, 2026
….3.3) (#10)

## Summary

Two sibling bugs in `process_x402_settle`'s verify → settle path, both
surfaced once #9 (1.3.2) cleared the facilitator-wiring layer.

### 1) Dict `payload` not coerced to typed `PaymentPayload`

`verify_x402_request` returns `payload` as a plain dict (the result of
`json.loads(base64.b64decode(X-Payment))`), but x402 2.9's
`verify_payment` / `settle_payment` call `payload.get_scheme()` and
other Pydantic-model methods on it. Without coercion, prod store crashed
at the verify leg with:

```
AttributeError("'dict' object has no attribute 'get_scheme'")
```

New `_coerce_payment_payload()` routes by `x402Version` field — `1` →
`PaymentPayloadV1` (flat top-level shape); anything else →
`PaymentPayload` (v2 nested under `accepted`). Falls back to the
original input on any failure so caller-typed instances pass through
unchanged. Both verify **and** settle legs now receive the coerced
payload (so the post-verify settle leg also sees a typed model).

### 2) Pydantic `SettleResponse` not JSON-serializable for the response
header

x402 2.9's `settle_payment` returns a `SettleResponse` Pydantic model.
The helper was doing plain `json.dumps(settle_result)` to base64-encode
the X-Payment-Response header — that raises `TypeError("Object of type
SettleResponse is not JSON serializable")`. Caught by the surrounding
`except Exception`, the helper would mark the settle as `settle_failed`
**after the on-chain settle had already succeeded** — payment taken,
order shows as failed.

New `_settle_result_to_json_bytes()` uses
`model_dump_json(by_alias=True)` for Pydantic models (so emitted keys
match the wire shape — `errorReason` / `errorMessage` camelCase) and
falls through to `json.dumps` for plain dicts (older x402 / test stubs).

## Verification

Live integration test against the real Coinbase CDP facilitator (with
prod CDP creds, mainnet) confirmed:
1. Dict → typed `PaymentPayload` happens (`get_scheme()` resolves on the
model).
2. `verify_payment` is reached with the typed model.
3. CDP receives the request, validates through schema, returns a
structured 400 (rejected the synth signature) — **no `AttributeError`
anywhere**.

The CDP rejection at the schema layer (not the type layer) is the
strongest signal short of an actual settle. Real `agentscore-pay`-signed
payloads conform to the x402V2 schema and will pass.

## Tests
- New: `coerces_dict_payload_v2_to_typed_payment_payload` — asserts both
verify + settle legs see the typed `PaymentPayload` and `get_scheme()`
resolves.
- New: `passes_typed_payment_payload_unchanged` — caller-typed instance
flows through.
- New: `serializes_pydantic_settle_result_to_payment_response_header` —
Pydantic `SettleResponse` round-trips to base64'd JSON with
`errorReason` (camelCase wire key).
- New: `serializes_dict_settle_result_for_legacy_stubs` — plain-dict
settle results still serialize.
- Full suite: **723 passed, 3 skipped, 95.30% coverage**.

## Test plan
- [ ] CI green.
- [ ] Tag `v1.3.3`, push tag → trigger PyPI publish.
- [ ] Bump `core/store/uv.lock` to 1.3.3 + fix store's `tx_hash`
extraction (companion bug — store reads `settle_obj.get("transaction")`
only when isinstance dict; with 1.3.3 the inner `settle_result` will be
a Pydantic model, so the dict branch is skipped and tx_hash silently
stays `None`).
- [ ] Deploy store prod.
- [ ] T4 base mainnet smoke against agents.agentscore.sh — expect `200 +
order` with non-null `tx_hash`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant