[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs#608
Open
bussyjd wants to merge 1 commit into
Open
Conversation
f85ae45 to
df88e03
Compare
…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.
df88e03 to
278e85a
Compare
3d8e4e9 to
7e3df3c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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) populatesRouteRule.Cardfromspec.payment.cardwhenmethod=card, so the verifier actually gates card offers (matchPaidRouteFull/HandleProxydispatch onrule.IsCard()). Crypto offers untouched (test asserts a crypto offer produces no card route); card routes still requireRoutePublishedlike crypto.2. Auth/capture split + SPT replay defense
Two-phase
cardGateway: authorize a manual-capture Stripe PaymentIntent before serving → capture only after a<400upstream 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 preservehttp.ErrAbortHandler).3. Stripe key + docs
The verifier reads
STRIPE_SECRET_KEYfrom thex402-secretsSecret via an optionalsecretKeyRefenv — no new RBAC, crypto-only stacks unaffected. Added the key +STRIPE_NETWORK_IDto.env.exampleand a "Credit-card payments (MPP)" README section. CLI gainsobol sell http --pay-with card --stripe-network-id(env defaultSTRIPE_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-levelshared_payment_granted_tokenper thecp0x-org/mppxreference (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 --> mainComponent view — one route, two settlement engines
flowchart TB buyer["Buyer / agent"] -->|"HTTPS via Cloudflare tunnel"| traefik["Traefik (Gateway API)<br/>HTTPRoute /services/<name>/*"] 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)"| VControl 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/<name>/* 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()"]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) endAuthorize → 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), 502Crypto 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"]Tests / validation
Two-phase lifecycle against a mock Stripe httptest server (authorize→requires_capture, capture→succeeded, cancel);
serveCardGatedsuccess / auth-failure / upstream-failure / capture-failure / panic / replay paths; replay guard; currency decimals;routeRuleFromOffercard 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).