Skip to content

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs#608

Open
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production
Open

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs#608
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production

Conversation

@bussyjd

@bussyjd bussyjd commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Takes the credit-card spike (#606) to a production-shaped implementation. Stacked on #606; flows into integration/v0.11.0-rc1.

1. Wire it up

routeRuleFromOffer (internal/x402/serviceoffer_source.go) populates RouteRule.Card from spec.payment.card when method=card, so the verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch on rule.IsCard()). Crypto offers untouched (test asserts a crypto offer produces no card route); card routes still require RoutePublished like crypto.

2. Auth/capture split + SPT replay defense

Two-phase cardGateway: authorize a manual-capture Stripe PaymentIntent before serving → capture only after a <400 upstream response → cancel the hold on upstream failure, capture failure, or an upstream that never responds (panic / no write). Per-pod SPT replay guard rejects reuse of a single-use Shared Payment Token. Capture/cancel run on detached contexts so a client disconnect can't cancel a money operation; a deferred reconcile cancels + releases on panic (and re-panics to preserve http.ErrAbortHandler).

3. Stripe key + docs

The verifier reads STRIPE_SECRET_KEY from the x402-secrets Secret via an optional secretKeyRef env — no new RBAC, crypto-only stacks unaffected. Added the key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)" README section. CLI gains obol sell http --pay-with card --stripe-network-id (env default STRIPE_NETWORK_ID).

4. Non-2-decimal currencies

currencyMinorUnits() maps ISO-4217 minor units (jpy=0, bhd/kwd=3, default 2); the Stripe amount uses it instead of a hardcoded 2. The SPT is the top-level shared_payment_granted_token per the cp0x-org/mppx reference (documented for live-Stripe validation).


Architecture

PR stack (all merge into the rc)

flowchart BT
    p3["#608 feat/mpp-card-production<br/>wire + authorize/capture/cancel + replay + docs"]
    p2["#606 feat/mpp-card-verifier-seam-spike<br/>buildCardRequirement + cardSettleFunc"]
    p1["#605 feat/mpp-card-payment-method<br/>CRD method+card+CEL · CLI --pay-with card"]
    rc["integration/v0.11.0-rc1"]
    main["main"]
    p3 --> p2 --> p1 --> rc --> main
Loading

Component view — one route, two settlement engines

flowchart TB
    buyer["Buyer / agent"] -->|"HTTPS via Cloudflare tunnel"| traefik["Traefik (Gateway API)<br/>HTTPRoute /services/&lt;name&gt;/*"]
    traefik -->|backendRef| HP

    subgraph V["x402-verifier · x402 ns · replicas:1"]
        HP["HandleProxy"] --> MR{"matchPaidRouteFull<br/>rule.IsCard()?"}
        MR -->|"no — crypto (unchanged)"| C["ForwardAuth +<br/>facilitator verify/settle"]
        MR -->|"yes — card"| D["serveCardGated<br/>authorize / capture / cancel"]
    end

    D -->|HTTPS| stripe["api.stripe.com<br/>/v1/payment_intents (+ /capture + /cancel)"]
    C --> upstream["upstream Service<br/>ollama / litellm / any svc"]
    D --> upstream
    secret["x402-secrets Secret<br/>STRIPE_SECRET_KEY"] -.->|"optional env (no RBAC change)"| V
Loading

Control plane — how a card offer becomes a live route

flowchart TB
    cli["obol sell http --pay-with card<br/>resolveCardPayment()"] -->|kubectl apply| so["ServiceOffer CR<br/>payment.method=card<br/>payment.card{account,currency,networkId}"]
    so -->|"CEL admission: card ⇒ card.account required"| ctrl["serviceoffer-controller<br/>ModelReady→…→RoutePublished→Ready"]
    ctrl -->|"HTTPRoute → verifier + Middleware"| live["live /services/&lt;name&gt;/* route"]
    so -.->|informer| src["serviceoffer_source.go<br/>routeRuleFromOffer()"]
    src -->|"method=card ⇒ rule.Card = CardRoute{…}<br/>Decimals = currencyMinorUnits(currency)"| rule["RouteRule (in-memory table)"]
    rule --> match["matchPaidRouteFull → IsCard()"]
Loading

Request sequence — the money path

sequenceDiagram
    autonumber
    participant B as Buyer
    participant V as x402-verifier
    participant S as Stripe
    participant U as Upstream

    B->>V: GET /services/my-api (no X-PAYMENT)
    V-->>B: 402 accepts[card] {amount(minor), currency, networkId}
    Note over B: mint Shared Payment Token spt_…
    B->>V: retry with X-PAYMENT = base64({spt})
    V->>V: guard.tryReserve(spt)
    V->>S: POST /payment_intents (manual capture, confirm, spt)
    S-->>V: requires_capture (pi_…)
    V->>U: proxy request
    alt upstream 2xx/3xx
        U-->>V: 200 + body
        V->>S: POST /payment_intents/pi_/capture
        S-->>V: succeeded
        V->>V: guard.consume(spt)
        V-->>B: 200 + body + X-PAYMENT-RESPONSE(pi_)
    else upstream 4xx/5xx · capture fails · panic / no-write
        V->>S: POST /payment_intents/pi_/cancel
        V->>V: guard.release(spt)
        V-->>B: error status (buyer NOT charged)
    end
Loading

Authorize → capture / cancel state machine

stateDiagram-v2
    [*] --> RESERVED: X-PAYMENT, tryReserve(spt)
    RESERVED --> AUTHORIZED: authorize -> requires_capture
    RESERVED --> rel_auth: authorize error
    AUTHORIZED --> CAPTURED: upstream 2xx/3xx, capture -> succeeded
    AUTHORIZED --> canc_fail: upstream 4xx/5xx / panic / no-write
    AUTHORIZED --> canc_cap: capture error
    CAPTURED --> [*]: consume(spt), 200 + receipt
    rel_auth --> [*]: release(spt), 402 (retry allowed)
    canc_fail --> [*]: cancel + release(spt), pass-through status
    canc_cap --> [*]: cancel + release(spt), 502
Loading

Crypto vs card dispatch

flowchart TB
    M["matchPaidRouteFull(uri) → RouteRule"] --> Q{"rule.IsCard()?"}
    Q -->|"no (crypto, default)"| C1["BuildV2RequirementWithAsset<br/>scheme exact · USDC/OBOL · payTo 0x…"]
    Q -->|"yes (card)"| D1["buildCardRequirement<br/>scheme card · stripe · payTo acct_…"]
    C1 --> C2["X-PAYMENT = ERC-3009 / Permit2 voucher"]
    D1 --> D2["X-PAYMENT = {spt_…}"]
    C2 --> C3["facilitator /verify + /settle<br/>offline · on-chain · final"]
    D2 --> D3["Stripe authorize → capture/cancel<br/>online · custodial · reversible"]
Loading

Tests / validation

Two-phase lifecycle against a mock Stripe httptest server (authorize→requires_capture, capture→succeeded, cancel); serveCardGated success / auth-failure / upstream-failure / capture-failure / panic / replay paths; replay guard; currency decimals; routeRuleFromOffer card wiring; CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.

Review

Adversarial multi-agent review (payment-lifecycle / security-abuse / integration-regression): 0 confirmed P0/P1. No free-service bypass, no card-vs-crypto scheme confusion (dispatch is from the CRD, never buyer input), secret never logged, RBAC unchanged, crypto path byte-for-byte. The leaked-authorization edge it surfaced (panicking/non-writing upstream) is fixed here (deferred reconcile + test). Documented residuals: per-pod replay guard (verifier is single-replica), single cluster-wide Stripe key (per-offer Secret is the next step, gated on widening the verifier's resourceName-scoped secret RBAC).

@bussyjd bussyjd force-pushed the feat/mpp-card-production branch from f85ae45 to df88e03 Compare June 8, 2026 19:22
…pture + docs)

Takes the credit-card spike to a production-shaped implementation across four
fronts.

1. Wire it up (internal/x402/serviceoffer_source.go): routeRuleFromOffer now
   populates RouteRule.Card from spec.payment.card when method=card, so the
   verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch
   on rule.IsCard()). Currency-derived minor-unit decimals.

2. Auth/capture split + replay defense (internal/x402/card.go): the single
   charge is replaced by a two-phase cardGateway — authorize a manual-capture
   Stripe PaymentIntent BEFORE serving, CAPTURE only after a <400 upstream
   response, and CANCEL the hold on upstream/capture failure, so a buyer is
   never charged for a request that wasn't served. A per-pod SPT replay guard
   rejects reuse of a single-use Shared Payment Token. Capture/cancel run on
   detached contexts so a client disconnect can't cancel a money operation.

3. Stripe key + docs (item 3): the verifier reads STRIPE_SECRET_KEY from the
   x402-secrets Secret (optional env, crypto-only stacks unaffected). Added the
   key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)"
   README section (Stripe "Machine payments" account requirement, populate-the-
   secret recipe, and the per-offer-Secret / RBAC / single-replica scope notes).
   CLI gains `obol sell http --pay-with card --stripe-network-id` (env default
   STRIPE_NETWORK_ID) so card offers advertise a usable network id.

4. Non-2-decimal currencies (item 4): currencyMinorUnits() maps ISO-4217 minor
   units (jpy=0, bhd=3, default 2); buildCardRequirement uses it for the Stripe
   amount instead of a hardcoded 2. The SPT is passed as the top-level
   shared_payment_granted_token per the cp0x-org/mppx reference (documented for
   live-Stripe validation).

Tests: two-phase lifecycle against a mock Stripe httptest server (authorize ->
requires_capture, capture -> succeeded, cancel), serveCardGated success/auth-
failure/upstream-failure/capture-failure/replay paths with a fake gateway, the
replay guard, currency decimals, the routeRuleFromOffer card wiring, and the
new CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.

Stacked on feat/mpp-card-verifier-seam-spike; flows into integration/v0.11.0-rc1.
@bussyjd bussyjd force-pushed the feat/mpp-card-production branch from df88e03 to 278e85a Compare June 9, 2026 04:28
@bussyjd bussyjd force-pushed the feat/mpp-card-verifier-seam-spike branch from 3d8e4e9 to 7e3df3c Compare June 9, 2026 04:28
@bussyjd bussyjd changed the title feat(x402): productionize MPP credit-card path — wire + auth/capture + replay + docs [MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs Jun 9, 2026
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