From 7e3df3cc151bb28f4937d39740ecd2c22409ee99 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Mon, 8 Jun 2026 18:18:38 +0400 Subject: [PATCH] =?UTF-8?q?spike(x402):=20MPP=20credit-card=20seam=20?= =?UTF-8?q?=E2=80=94=20buildCardRequirement=20+=20Stripe=20cardSettleFunc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrate how the MPP credit-card method (Stripe stripe.charge) plugs into the existing x402 verifier without disturbing the crypto path. Grounded on the real wire from github.com/cp0x-org/mppx/stripe. - internal/x402/card.go (new): - buildCardRequirement(): emits the card option as a 402 accepts[] entry, mirroring the MPP stripe.charge challenge.request (amount in minor units + currency/decimals + methodDetails{networkId,paymentMethodTypes}). - cardSettleFunc + stripeCardSettler: charges a buyer's pre-authorized Shared Payment Token by creating+confirming a Stripe PaymentIntent (POST /v1/payment_intents, shared_payment_granted_token, confirm=true, Basic auth, idempotency key) — adapted from mppx. - parseCardCredential(): decodes the base64 X-PAYMENT card payload {spt, externalId} (bare or x402-wrapped). - serveCardGated(): in-process HandleProxy branch — 402 when unpaid, Stripe charge then proxy when paid, X-PAYMENT-RESPONSE receipt on success. - internal/x402/config.go: RouteRule.Card (*CardRoute) marks a card route. - internal/x402/verifier.go: matchPaidRouteFull + HandleProxy dispatch on rule.IsCard() to the card path, leaving the crypto path byte-for-byte. Tests (card_test.go): requirement shape, credential parsing (valid/invalid), Stripe form body, and the serveCardGated gate (402 unpaid / proxy on paid / 402 on settle failure) with a fake settler. SPIKE scope — clearly marked inline. Not wired end-to-end: - RouteRule.Card is not yet populated by the ServiceOffer route source (serviceoffer_source.go) from spec.payment.card — the integration follow-up. - Stripe secret key read from STRIPE_SECRET_KEY; production must read a per-offer k8s Secret. - Charges before serving (mppx Verify semantics); production should split authorize-before-serve from capture-after-success and persist references. Stacked on feat/mpp-card-payment-method (the CRD + --pay-with card flag). --- internal/x402/card.go | 296 +++++++++++++++++++++++++++++++++++++ internal/x402/card_test.go | 212 ++++++++++++++++++++++++++ internal/x402/config.go | 27 ++++ internal/x402/verifier.go | 13 ++ 4 files changed, 548 insertions(+) create mode 100644 internal/x402/card.go create mode 100644 internal/x402/card_test.go diff --git a/internal/x402/card.go b/internal/x402/card.go new file mode 100644 index 00000000..85356f40 --- /dev/null +++ b/internal/x402/card.go @@ -0,0 +1,296 @@ +package x402 + +// SPIKE — MPP credit-card (Stripe stripe.charge) seam for the seller gateway. +// +// This file demonstrates how the MPP credit-card payment method plugs into +// the existing x402 verifier without disturbing the crypto path: +// +// - buildCardRequirement(): emits the card option as a 402 `accepts[]` +// entry, mirroring the MPP stripe.charge challenge.request shape +// (amount/currency/decimals + methodDetails{networkId,paymentMethodTypes}). +// - cardSettleFunc / stripeCardSettler: charges the buyer's pre-authorized +// Shared Payment Token (SPT) by creating+confirming a Stripe +// PaymentIntent, adapted from github.com/cp0x-org/mppx/stripe. +// - serveCardGated(): the in-process HandleProxy branch that gates a card +// route — 402 when unpaid, Stripe charge then proxy when paid. +// +// What is intentionally STUBBED for this spike (called out inline): +// - RouteRule.Card is not yet populated by the ServiceOffer route source +// (serviceoffer_source.go); wiring that is the integration follow-up. +// - The Stripe secret key is read from STRIPE_SECRET_KEY; production must +// read a per-offer Kubernetes Secret so one verifier can gate many +// sellers without sharing a key. +// - Charge happens before proxying (mppx Verify semantics). A production +// build should split authorize-before-serve from capture-after-success +// and persist the PaymentIntent reference for idempotent retries. + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "os" + "strings" + "time" + + x402types "github.com/coinbase/x402/go/types" +) + +const ( + // cardScheme is the PaymentRequirements.Scheme used for the card option + // so a card-capable buyer can distinguish it from the x402 "exact" + // crypto option co-offered on the same route. + cardScheme = "card" + // cardNetworkStripe identifies the Stripe rail in the card requirement. + cardNetworkStripe = "stripe" + + stripePaymentIntentsURL = "https://api.stripe.com/v1/payment_intents" +) + +// IsCard reports whether this route is gated by the MPP credit-card method +// rather than x402 on-chain settlement. +func (r *RouteRule) IsCard() bool { return r != nil && r.Card != nil } + +// cardDecimals returns the currency minor-unit precision, defaulting to 2. +func (c *CardRoute) cardDecimals() int { + if c != nil && c.Decimals > 0 { + return c.Decimals + } + return 2 +} + +// cardCurrency returns the ISO-4217 currency, defaulting to "usd". +func (c *CardRoute) cardCurrency() string { + if c != nil && c.Currency != "" { + return strings.ToLower(c.Currency) + } + return "usd" +} + +// cardPaymentMethodTypes returns the advertised Stripe payment-method types, +// defaulting to ["card"]. +func (c *CardRoute) cardPaymentMethodTypes() []string { + if c != nil && len(c.PaymentMethodTypes) > 0 { + return c.PaymentMethodTypes + } + return []string{"card"} +} + +// buildCardRequirement builds the 402 `accepts[]` entry advertising the MPP +// credit-card (Stripe) option for a card route. The Amount is in currency +// minor units (e.g. cents) to match Stripe's PaymentIntent API; the human +// decimal price is mirrored under Extra.request for MPP-aware clients that +// normalize against `decimals`. +func buildCardRequirement(rule *RouteRule) x402types.PaymentRequirements { + card := rule.Card + decimals := card.cardDecimals() + currency := card.cardCurrency() + pmt := card.cardPaymentMethodTypes() + amountMinor := decimalToAtomic(rule.Price, decimals) + + return x402types.PaymentRequirements{ + Scheme: cardScheme, + Network: cardNetworkStripe, + Amount: amountMinor, + Asset: "", // no on-chain asset for card settlement + PayTo: card.Account, + MaxTimeoutSeconds: 300, + Extra: map[string]any{ + "method": cardNetworkStripe, + "intent": "charge", + "currency": currency, + "decimals": decimals, + "networkId": card.NetworkID, + "paymentMethodTypes": pmt, + // Mirror the MPP stripe.charge challenge.request so an MPP card + // client can mint a Shared Payment Token against this offer. + "request": map[string]any{ + "amount": rule.Price, + "currency": currency, + "decimals": decimals, + "methodDetails": map[string]any{ + "networkId": card.NetworkID, + "paymentMethodTypes": pmt, + }, + }, + }, + } +} + +// cardCredential is the buyer-supplied card payment payload carried (base64 +// JSON) in the X-PAYMENT header: a Stripe Shared Payment Token plus an +// optional client-side external id for reconciliation. +type cardCredential struct { + SPT string `json:"spt"` + ExternalID string `json:"externalId,omitempty"` +} + +func (c cardCredential) normalize() (cardCredential, error) { + c.SPT = strings.TrimSpace(c.SPT) + if !strings.HasPrefix(c.SPT, "spt_") { + return cardCredential{}, errors.New(`card credential spt must start with "spt_"`) + } + return c, nil +} + +// parseCardCredential decodes the base64 X-PAYMENT card payload. It accepts +// both the bare payload ({spt,externalId}) and an x402-style wrapper +// ({payload:{...}}) so the spike is flexible about the buyer encoding. +func parseCardCredential(header string) (cardCredential, error) { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(header)) + if err != nil { + return cardCredential{}, fmt.Errorf("invalid card credential base64: %w", err) + } + var direct cardCredential + if err := json.Unmarshal(raw, &direct); err == nil && direct.SPT != "" { + return direct.normalize() + } + var wrapper struct { + Payload cardCredential `json:"payload"` + } + if err := json.Unmarshal(raw, &wrapper); err == nil && wrapper.Payload.SPT != "" { + return wrapper.Payload.normalize() + } + return cardCredential{}, errors.New("card credential missing spt") +} + +// cardSettleFunc charges a buyer's pre-authorized card credential for one +// request and returns an opaque settlement reference (the Stripe +// PaymentIntent id) on success. Unlike the deferred, offline x402 facilitator +// settle, a card charge is synchronous and online, so implementations must be +// safe to call on the request path. +type cardSettleFunc func(ctx context.Context, card *CardRoute, amountMinorUnits, currency string, cred cardCredential) (reference string, err error) + +// stripeCardSettler implements cardSettleFunc against the Stripe +// PaymentIntents API, adapted from github.com/cp0x-org/mppx/stripe. +type stripeCardSettler struct { + httpClient *http.Client + // secretKey returns the seller's Stripe secret key. SPIKE: sourced from + // STRIPE_SECRET_KEY; production must read a per-offer Kubernetes Secret. + secretKey func() string +} + +func newStripeCardSettler() *stripeCardSettler { + return &stripeCardSettler{ + httpClient: &http.Client{Timeout: 20 * time.Second}, + secretKey: func() string { return strings.TrimSpace(os.Getenv("STRIPE_SECRET_KEY")) }, + } +} + +// defaultCardSettler is the package default used by serveCardGated. Kept as a +// package var (not a Verifier field) so the spike doesn't disturb the +// Verifier constructor; serveCardGated takes the settleFunc so tests can +// inject a fake. +var defaultCardSettler = newStripeCardSettler() + +// buildPaymentIntentForm is the form body for Stripe PaymentIntent +// create+confirm. Split out so it can be unit-tested without network. +func buildPaymentIntentForm(amountMinorUnits, currency, spt string) url.Values { + form := url.Values{} + form.Set("amount", amountMinorUnits) + form.Set("currency", currency) + form.Set("confirm", "true") + form.Set("shared_payment_granted_token", spt) + form.Set("automatic_payment_methods[enabled]", "true") + form.Set("automatic_payment_methods[allow_redirects]", "never") + return form +} + +// settle creates+confirms a Stripe PaymentIntent from the buyer's SPT. +func (s *stripeCardSettler) settle(ctx context.Context, card *CardRoute, amountMinorUnits, currency string, cred cardCredential) (string, error) { + key := s.secretKey() + if key == "" { + return "", errors.New("stripe secret key not configured (STRIPE_SECRET_KEY)") + } + form := buildPaymentIntentForm(amountMinorUnits, currency, cred.SPT) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, stripePaymentIntentsURL, strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("build stripe request: %w", err) + } + // Stripe uses HTTP Basic with the secret key as the username, no password. + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(key+":"))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // Idempotency keyed on the single-use SPT so retries don't double-charge. + req.Header.Set("Idempotency-Key", "obol_"+cred.SPT) + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("stripe request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("stripe PaymentIntent failed (HTTP %d)", resp.StatusCode) + } + var body struct { + ID string `json:"id"` + Status string `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", fmt.Errorf("decode stripe response: %w", err) + } + if body.Status != "succeeded" { + return "", fmt.Errorf("stripe PaymentIntent status: %s", body.Status) + } + return body.ID, nil +} + +// cardReceiptJSON builds the X-PAYMENT-RESPONSE body surfaced to the buyer +// after a successful card charge. +func cardReceiptJSON(reference string) []byte { + b, err := json.Marshal(map[string]string{ + "method": cardNetworkStripe, + "intent": "charge", + "reference": reference, + }) + if err != nil { + return []byte("{}") + } + return b +} + +// serveCardGated is the in-process seller gate for MPP credit-card offers, +// invoked from Verifier.HandleProxy when the matched route is a card route. +// SPIKE: charges before proxying (mppx Verify semantics) and uses the JSON +// 402 (no HTML page); see the file header for the production refinements. +func (v *Verifier) serveCardGated( + w http.ResponseWriter, + r *http.Request, + rule *RouteRule, + requirement x402types.PaymentRequirements, + extensions map[string]any, + proxy http.Handler, + settle cardSettleFunc, +) { + reqs := []x402types.PaymentRequirements{requirement} + + paymentHeader := r.Header.Get("X-PAYMENT") + if paymentHeader == "" { + sendPaymentRequiredJSON(w, r, reqs, extensions) + return + } + + cred, err := parseCardCredential(paymentHeader) + if err != nil { + log.Printf("x402-card: bad credential for %s/%s: %v", rule.OfferNamespace, rule.OfferName, err) + sendPaymentRequiredJSON(w, r, reqs, extensions) + return + } + + currency, _ := requirement.Extra["currency"].(string) + reference, err := settle(r.Context(), rule.Card, requirement.Amount, currency, cred) + if err != nil { + log.Printf("x402-card: settle failed for %s/%s: %v", rule.OfferNamespace, rule.OfferName, err) + sendPaymentRequiredJSON(w, r, reqs, extensions) + return + } + + // Charge succeeded — surface the receipt and proxy upstream. + w.Header().Set("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(cardReceiptJSON(reference))) + proxy.ServeHTTP(w, r) +} diff --git a/internal/x402/card_test.go b/internal/x402/card_test.go new file mode 100644 index 00000000..649bad03 --- /dev/null +++ b/internal/x402/card_test.go @@ -0,0 +1,212 @@ +package x402 + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func cardTestRule() *RouteRule { + return &RouteRule{ + Pattern: "/services/card-foo/*", + Price: "0.01", + OfferNamespace: "default", + OfferName: "card-foo", + Card: &CardRoute{ + Provider: "stripe", + Account: "acct_test123", + Currency: "usd", + NetworkID: "stripenet_abc", + }, + } +} + +func TestBuildCardRequirement(t *testing.T) { + req := buildCardRequirement(cardTestRule()) + + if req.Scheme != cardScheme { + t.Errorf("scheme = %q, want %q", req.Scheme, cardScheme) + } + if req.Network != cardNetworkStripe { + t.Errorf("network = %q, want %q", req.Network, cardNetworkStripe) + } + if req.PayTo != "acct_test123" { + t.Errorf("payTo = %q, want acct_test123", req.PayTo) + } + // "0.01" usd (2 decimals) -> 1 minor unit (cent). + if req.Amount != "1" { + t.Errorf("amount = %q, want 1 (minor units)", req.Amount) + } + if req.Asset != "" { + t.Errorf("asset = %q, want empty (no on-chain asset for card)", req.Asset) + } + if req.Extra["method"] != cardNetworkStripe { + t.Errorf("extra.method = %v, want stripe", req.Extra["method"]) + } + if req.Extra["currency"] != "usd" { + t.Errorf("extra.currency = %v, want usd", req.Extra["currency"]) + } + if req.Extra["networkId"] != "stripenet_abc" { + t.Errorf("extra.networkId = %v, want stripenet_abc", req.Extra["networkId"]) + } + // Defaulted payment-method types. + pmt, ok := req.Extra["paymentMethodTypes"].([]string) + if !ok || len(pmt) != 1 || pmt[0] != "card" { + t.Errorf("extra.paymentMethodTypes = %v, want [card]", req.Extra["paymentMethodTypes"]) + } +} + +func TestParseCardCredential(t *testing.T) { + b64 := func(v any) string { + b, _ := json.Marshal(v) + return base64.StdEncoding.EncodeToString(b) + } + + t.Run("bare payload", func(t *testing.T) { + cred, err := parseCardCredential(b64(map[string]string{"spt": "spt_abc", "externalId": "e1"})) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cred.SPT != "spt_abc" || cred.ExternalID != "e1" { + t.Errorf("got %+v", cred) + } + }) + + t.Run("wrapped payload", func(t *testing.T) { + cred, err := parseCardCredential(b64(map[string]any{"payload": map[string]string{"spt": "spt_xyz"}})) + if err != nil || cred.SPT != "spt_xyz" { + t.Fatalf("got %+v err=%v", cred, err) + } + }) + + for _, bad := range []struct { + name, header string + }{ + {"bad base64", "!!!not-base64!!!"}, + {"missing spt", b64(map[string]string{"externalId": "e1"})}, + {"wrong prefix", b64(map[string]string{"spt": "tok_abc"})}, + } { + t.Run(bad.name, func(t *testing.T) { + if _, err := parseCardCredential(bad.header); err == nil { + t.Errorf("expected error for %s", bad.name) + } + }) + } +} + +func TestBuildPaymentIntentForm(t *testing.T) { + form := buildPaymentIntentForm("1", "usd", "spt_abc") + want := map[string]string{ + "amount": "1", + "currency": "usd", + "confirm": "true", + "shared_payment_granted_token": "spt_abc", + "automatic_payment_methods[enabled]": "true", + "automatic_payment_methods[allow_redirects]": "never", + } + for k, v := range want { + if form.Get(k) != v { + t.Errorf("form[%q] = %q, want %q", k, form.Get(k), v) + } + } +} + +func TestServeCardGated_NoPayment402(t *testing.T) { + rule := cardTestRule() + req := buildCardRequirement(rule) + proxied := false + proxy := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { proxied = true }) + + r := httptest.NewRequest(http.MethodPost, "/services/card-foo/x", nil) + w := httptest.NewRecorder() + + (&Verifier{}).serveCardGated(w, r, rule, req, nil, proxy, func(context.Context, *CardRoute, string, string, cardCredential) (string, error) { + t.Fatal("settle must not be called without a credential") + return "", nil + }) + + if w.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want 402", w.Code) + } + if proxied { + t.Error("upstream must not be proxied on 402") + } + var body struct { + Accepts []struct { + Scheme string `json:"scheme"` + } `json:"accepts"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode 402 body: %v", err) + } + if len(body.Accepts) != 1 || body.Accepts[0].Scheme != cardScheme { + t.Errorf("402 accepts = %+v, want one card entry", body.Accepts) + } +} + +func TestServeCardGated_PaidProxies(t *testing.T) { + rule := cardTestRule() + req := buildCardRequirement(rule) + proxy := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "upstream-ok") + }) + + cred, _ := json.Marshal(map[string]string{"spt": "spt_live", "externalId": "e9"}) + r := httptest.NewRequest(http.MethodPost, "/services/card-foo/x", nil) + r.Header.Set("X-PAYMENT", base64.StdEncoding.EncodeToString(cred)) + w := httptest.NewRecorder() + + var gotAmount, gotCurrency, gotSPT, gotAccount string + settle := func(_ context.Context, card *CardRoute, amount, currency string, c cardCredential) (string, error) { + gotAmount, gotCurrency, gotSPT, gotAccount = amount, currency, c.SPT, card.Account + return "pi_123", nil + } + + (&Verifier{}).serveCardGated(w, r, rule, req, nil, proxy, settle) + + if w.Code != http.StatusOK || w.Body.String() != "upstream-ok" { + t.Fatalf("status=%d body=%q, want 200/upstream-ok", w.Code, w.Body.String()) + } + if gotAmount != "1" || gotCurrency != "usd" || gotSPT != "spt_live" || gotAccount != "acct_test123" { + t.Errorf("settle args: amount=%q currency=%q spt=%q account=%q", gotAmount, gotCurrency, gotSPT, gotAccount) + } + // Receipt header references the PaymentIntent. + hdr := w.Header().Get("X-PAYMENT-RESPONSE") + if hdr == "" { + t.Fatal("missing X-PAYMENT-RESPONSE header") + } + dec, _ := base64.StdEncoding.DecodeString(hdr) + var receipt map[string]string + _ = json.Unmarshal(dec, &receipt) + if receipt["reference"] != "pi_123" || receipt["method"] != cardNetworkStripe { + t.Errorf("receipt = %v, want reference pi_123 / method stripe", receipt) + } +} + +func TestServeCardGated_SettleFailure402(t *testing.T) { + rule := cardTestRule() + req := buildCardRequirement(rule) + proxied := false + proxy := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { proxied = true }) + + cred, _ := json.Marshal(map[string]string{"spt": "spt_decline"}) + r := httptest.NewRequest(http.MethodPost, "/services/card-foo/x", nil) + r.Header.Set("X-PAYMENT", base64.StdEncoding.EncodeToString(cred)) + w := httptest.NewRecorder() + + (&Verifier{}).serveCardGated(w, r, rule, req, nil, proxy, func(context.Context, *CardRoute, string, string, cardCredential) (string, error) { + return "", io.ErrUnexpectedEOF // simulate a declined/erroring charge + }) + + if w.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want 402 on settle failure", w.Code) + } + if proxied { + t.Error("upstream must not be proxied when settlement fails") + } +} diff --git a/internal/x402/config.go b/internal/x402/config.go index 545b4d42..7782a9a2 100644 --- a/internal/x402/config.go +++ b/internal/x402/config.go @@ -128,6 +128,33 @@ type RouteRule struct { // 402 page's primary Buy card so users can copy a fully-formed // `obol buy inference --model ...` command. Model string `yaml:"model,omitempty"` + + // Card, when non-nil, marks this route as gated by the MPP credit-card + // method (Stripe stripe.charge) instead of x402 on-chain settlement. + // Mirrors ServiceOffer.spec.payment.card. SPIKE: the serviceoffer route + // source does not yet populate this from the CRD — see card.go. + Card *CardRoute `yaml:"card,omitempty"` +} + +// CardRoute carries the per-route MPP credit-card (Stripe) terms used when +// RouteRule.Card is non-nil. It is the card-method analog of the +// PayTo/Network/Asset fields above. +type CardRoute struct { + // Provider is the card payment provider (only "stripe" today). + Provider string `yaml:"provider,omitempty"` + // Account is the Stripe destination account id (acct_...) that receives + // settled funds — the card analog of PayTo. + Account string `yaml:"account,omitempty"` + // Currency is the ISO-4217 charge currency (e.g. "usd"). + Currency string `yaml:"currency,omitempty"` + // Decimals is the currency's minor-unit precision (2 for usd/eur). + Decimals int `yaml:"decimals,omitempty"` + // NetworkID is the Stripe "machine payments" Business Network id, + // advertised in the 402 challenge so MPP clients can mint an SPT. + NetworkID string `yaml:"networkId,omitempty"` + // PaymentMethodTypes are the accepted Stripe payment-method types, + // advertised in the challenge (defaults to ["card"]). + PaymentMethodTypes []string `yaml:"paymentMethodTypes,omitempty"` } // LoadConfig reads and parses a pricing configuration YAML file. diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index f93d2c62..0c75cad0 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -234,6 +234,13 @@ func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { return } + // SPIKE: MPP credit-card offers gate through Stripe instead of the x402 + // facilitator ForwardAuth path. + if rule.IsCard() { + v.serveCardGated(w, r, rule, requirement, extensions, proxy, defaultCardSettler.settle) + return + } + wallet := cfg.Wallet if rule.PayTo != "" { wallet = rule.PayTo @@ -313,6 +320,12 @@ func (v *Verifier) matchPaidRouteFull(cfg *PricingConfig, uri string) (*RouteRul return nil, x402types.PaymentRequirements{}, nil, nil, ChainInfo{}, AssetInfo{}, false } + // SPIKE: card routes settle off-chain via Stripe; skip chain/asset + // resolution and emit the MPP credit-card 402 option instead. + if rule.IsCard() { + return rule, buildCardRequirement(rule), nil, prometheusLabels(rule), ChainInfo{}, AssetInfo{}, true + } + wallet := cfg.Wallet if rule.PayTo != "" { wallet = rule.PayTo