diff --git a/.env.example b/.env.example index 475807d6..11ae4a08 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,16 @@ ANTHROPIC_API_KEY= # Required for TestIntegration_OpenAIInference OPENAI_API_KEY= + +# ── MPP credit-card payments (Stripe) ────────────────────────────────────── +# Seller-side credit-card settlement via the Machine Payments Protocol (MPP). +# Requires a Stripe account with "Machine payments" enabled. See the +# "Credit-card payments (MPP)" section of README.md. +# +# Consumed by the x402-verifier (sourced from the x402-secrets Secret in the +# `x402` namespace) to authorize/capture Stripe PaymentIntents for card offers. +STRIPE_SECRET_KEY= +# Your Stripe "machine payments" network id, advertised in the 402 challenge so +# card clients can mint a Shared Payment Token. Default for +# `obol sell http --pay-with card --stripe-network-id`. +STRIPE_NETWORK_ID= diff --git a/README.md b/README.md index 818f9f3f..2420aae0 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,60 @@ obol openclaw skills remove # remove via openclaw CLI in pod Skills are delivered via host-path PVC injection — no ConfigMap size limits, works before pod readiness, and survives pod restarts. +## Credit-card payments (MPP) + +Alongside the default x402 on-chain (stablecoin) payment path, sellers can accept +**credit-card** payments via the [Machine Payments Protocol](https://mpp.dev) (MPP, +the Stripe + Tempo HTTP-402 standard). A card offer is gated on the same +`/services//*` route as a crypto offer — the payment method is selected per +offer. + +```bash +# Expose an upstream as a card-paid endpoint (Stripe stripe.charge). +obol sell http my-api \ + --pay-with card \ + --stripe-account acct_1A2b3C4d \ # Stripe destination account (card analog of --pay-to) + --stripe-network-id stripenet_...\ # Stripe "machine payments" network id (or STRIPE_NETWORK_ID) + --card-currency usd \ + --upstream my-svc --port 8080 --price 0.01 +``` + +How it works: + +- The offer advertises a `card` option in its `402` challenge (amount in the + currency's **minor units** — cents for `usd`, whole yen for `jpy`, etc.). +- A card-capable buyer presents a Stripe **Shared Payment Token** (`spt_…`) in the + `X-PAYMENT` header. +- The verifier **authorizes** a manual-capture Stripe PaymentIntent before serving, + proxies to the upstream, then **captures** only after a successful (`<400`) + response — a failed upstream **cancels** the hold, so a buyer is never charged for + nothing. Each SPT is single-use (replay-guarded). + +### Requirements & configuration + +- A **Stripe account with "Machine payments" enabled** (a gated Stripe feature). +- `STRIPE_SECRET_KEY` — used by the `x402-verifier` to authorize/capture + PaymentIntents. It is read from the `x402-secrets` Secret in the `x402` namespace; + populate it before taking card payments: + + ```bash + kubectl -n x402 patch secret x402-secrets --type merge \ + -p '{"stringData":{"STRIPE_SECRET_KEY":"sk_live_..."}}' + kubectl -n x402 rollout restart deploy/x402-verifier + ``` + +- `STRIPE_NETWORK_ID` — your Stripe "machine payments" network id, advertised in the + 402 challenge so clients can mint an SPT. It is a host/CLI value (default for + `--stripe-network-id`); add both to your `.env` from `.env.example`. + +> **Note on scope.** Card offers are not ERC-8004 registered (no on-chain identity). +> The Stripe key is currently a single cluster-wide value in `x402-secrets`; a +> per-offer/per-namespace Secret is the production direction but is gated on widening +> the verifier's deliberately `resourceName`-scoped Secret RBAC. The SPT replay guard +> is per-pod (the verifier runs single-replica). The SPT is passed as the top-level +> Stripe form field `shared_payment_granted_token` per the `cp0x-org/mppx` reference — +> validate against your live Stripe account before relying on it in production. + ## Public Access (Cloudflare Tunnel) Expose your stack to the internet via Cloudflare Tunnel: diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 02f06790..7728bd2d 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -126,13 +126,20 @@ func resolveCardPayment(cmd *cli.Command, price map[string]any) (map[string]any, if !currencyRe.MatchString(currency) { return nil, fmt.Errorf("invalid --card-currency %q: expected a 3-letter ISO-4217 code like usd", currency) } + card := map[string]any{ + "provider": "stripe", + "account": account, + "currency": currency, + } + // The Stripe "machine payments" network id is advertised in the 402 + // challenge so MPP card clients can mint a Shared Payment Token. Defaults + // from the STRIPE_NETWORK_ID env var. + if networkID := strings.TrimSpace(cmd.String("stripe-network-id")); networkID != "" { + card["networkId"] = networkID + } return map[string]any{ - "method": payMethodCard, - "card": map[string]any{ - "provider": "stripe", - "account": account, - "currency": currency, - }, + "method": payMethodCard, + "card": card, "maxTimeoutSeconds": cmd.Int("max-timeout"), "price": price, }, nil @@ -647,6 +654,11 @@ Examples: Usage: "ISO-4217 currency for card charges", Value: "usd", }, + &cli.StringFlag{ + Name: "stripe-network-id", + Usage: "Stripe \"machine payments\" network id advertised in the 402 challenge (so MPP card clients can mint a Shared Payment Token)", + Sources: cli.EnvVars("STRIPE_NETWORK_ID"), + }, &cli.StringFlag{ Name: "upstream", Usage: "Upstream service name", diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index cb83c980..e3b36fd2 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -262,7 +262,7 @@ func TestSellHTTP_Flags(t *testing.T) { "namespace", "upstream", "port", "health-path", "path", "max-timeout", "register", "no-register", "register-name", "register-description", "register-image", - "pay-with", "stripe-account", "card-currency", + "pay-with", "stripe-account", "card-currency", "stripe-network-id", ) assertStringDefault(t, flags, "chain", "base") @@ -306,6 +306,7 @@ func runCardResolve(t *testing.T, args ...string) (map[string]any, error) { &cli.StringFlag{Name: "pay-with", Value: payMethodCard}, &cli.StringFlag{Name: "stripe-account"}, &cli.StringFlag{Name: "card-currency", Value: "usd"}, + &cli.StringFlag{Name: "stripe-network-id"}, &cli.IntFlag{Name: "max-timeout", Value: 300}, }, Action: func(_ context.Context, c *cli.Command) error { @@ -320,7 +321,7 @@ func runCardResolve(t *testing.T, args ...string) (map[string]any, error) { } func TestResolveCardPayment_Valid(t *testing.T) { - out, err := runCardResolve(t, "--stripe-account", "acct_1A2b3C4d", "--card-currency", "eur") + out, err := runCardResolve(t, "--stripe-account", "acct_1A2b3C4d", "--card-currency", "eur", "--stripe-network-id", "stripenet_test") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -340,6 +341,9 @@ func TestResolveCardPayment_Valid(t *testing.T) { if card["currency"] != "eur" { t.Errorf("card.currency = %v, want eur", card["currency"]) } + if card["networkId"] != "stripenet_test" { + t.Errorf("card.networkId = %v, want stripenet_test", card["networkId"]) + } if _, ok := out["price"].(map[string]any); !ok { t.Errorf("price block missing: %v", out["price"]) } diff --git a/internal/embed/infrastructure/base/templates/x402.yaml b/internal/embed/infrastructure/base/templates/x402.yaml index 4b2951fa..3d33cb4d 100644 --- a/internal/embed/infrastructure/base/templates/x402.yaml +++ b/internal/embed/infrastructure/base/templates/x402.yaml @@ -57,6 +57,12 @@ metadata: type: Opaque stringData: WALLET_ADDRESS: "" + # Stripe secret key for MPP credit-card (stripe.charge) offers. Empty on a + # crypto-only stack; populate it (e.g. `kubectl -n x402 patch secret + # x402-secrets --type merge -p '{"stringData":{"STRIPE_SECRET_KEY":"sk_live_..."}}'`) + # to let the verifier authorize/capture card PaymentIntents. See README + # "Credit-card payments (MPP)". + STRIPE_SECRET_KEY: "" --- apiVersion: v1 @@ -267,6 +273,16 @@ spec: - --config=/config/pricing.yaml - --listen=:8080 - --route-source=kube + env: + # MPP credit-card (Stripe) settlement key. Empty unless card offers + # are in use; optional=true keeps the verifier starting on + # crypto-only stacks where the key is unset. + - name: STRIPE_SECRET_KEY + valueFrom: + secretKeyRef: + name: x402-secrets + key: STRIPE_SECRET_KEY + optional: true volumeMounts: - name: pricing-config mountPath: /config diff --git a/internal/x402/card.go b/internal/x402/card.go index 85356f40..1f6407d9 100644 --- a/internal/x402/card.go +++ b/internal/x402/card.go @@ -1,28 +1,33 @@ package x402 -// SPIKE — MPP credit-card (Stripe stripe.charge) seam for the seller gateway. +// MPP credit-card (Stripe stripe.charge) settlement 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: +// Plugs the MPP credit-card method 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. +// - buildCardRequirement(): emits the card option as a 402 accepts[] entry, +// mirroring the MPP stripe.charge challenge.request (amount in currency +// minor units + currency/decimals + methodDetails{networkId, +// paymentMethodTypes}). +// - cardGateway / stripeCardGateway: a two-phase authorize -> capture/cancel +// against Stripe PaymentIntents (manual capture). The buyer's pre-authorized +// Shared Payment Token is AUTHORIZED before the upstream is served and only +// CAPTURED after a successful (<400) upstream response; a failed upstream +// CANCELS the authorization so the buyer is never charged for nothing. +// - serveCardGated(): the in-process HandleProxy branch — authorize-before- +// serve, capture-after-success, cancel-on-failure, with an in-memory SPT +// replay guard so a Shared Payment Token cannot be reused. // -// 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. +// Productionization notes (see README "Credit-card payments (MPP)"): +// - The Stripe secret is read from STRIPE_SECRET_KEY; the verifier Deployment +// sources it from the x402-secrets Secret. A per-offer/per-namespace Secret +// needs the verifier's resourceName-scoped secret RBAC to be widened +// deliberately and is intentionally deferred. +// - The replay guard is per-pod; the verifier runs single-replica, so this is +// sufficient today. A multi-replica verifier would need shared replay state. +// - The SPT is passed as the top-level form field shared_payment_granted_token +// per the cp0x-org/mppx reference; validate against a live Stripe "machine +// payments" account before relying on it in production. import ( "context" @@ -35,44 +40,72 @@ import ( "net/url" "os" "strings" + "sync" "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 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" + // defaultCardCurrency is the fallback ISO-4217 currency for card offers. + defaultCardCurrency = "usd" - stripePaymentIntentsURL = "https://api.stripe.com/v1/payment_intents" + // stripeAPIBase is the default Stripe API base URL (overridable on the + // gateway for tests). + stripeAPIBase = "https://api.stripe.com/v1" + + // cardStripeTimeout bounds each Stripe API call. Authorize/capture/cancel + // run on detached contexts so a client disconnect cannot cancel an + // in-flight money operation. + cardStripeTimeout = 20 * time.Second + + // sptReplayTTL is how long a seen Shared Payment Token stays blocked in the + // per-pod replay guard. SPTs are single-use and short-lived, so an hour is + // ample headroom over their validity window. + sptReplayTTL = time.Hour ) // 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. +// currencyMinorUnits returns the ISO-4217 minor-unit exponent (decimal places) +// for a currency, defaulting to 2. Stripe expects PaymentIntent amounts in the +// currency's smallest unit, which is not always cents (JPY has 0, BHD has 3). +func currencyMinorUnits(currency string) int { + switch strings.ToLower(strings.TrimSpace(currency)) { + case "jpy", "krw", "vnd", "clp", "isk", "bif", "djf", "gnf", "kmf", "pyg", "rwf", "ugx", "vuv", "xaf", "xof", "xpf": + return 0 + case "bhd", "iqd", "jod", "kwd", "omr", "tnd", "lyd": + return 3 + default: + return 2 + } +} + func (c *CardRoute) cardDecimals() int { - if c != nil && c.Decimals > 0 { + if c == nil { + return 2 + } + if c.Decimals > 0 { return c.Decimals } - return 2 + return currencyMinorUnits(c.Currency) } -// 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" + return defaultCardCurrency } -// 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 @@ -80,11 +113,11 @@ func (c *CardRoute) cardPaymentMethodTypes() []string { 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`. +// 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 for usd, whole yen for jpy) 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() @@ -122,8 +155,8 @@ func buildCardRequirement(rule *RouteRule) x402types.PaymentRequirements { } // 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. +// 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"` @@ -137,9 +170,8 @@ func (c cardCredential) normalize() (cardCredential, error) { 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. +// parseCardCredential decodes the base64 X-PAYMENT card payload. It accepts both +// the bare payload ({spt,externalId}) and an x402-style wrapper ({payload:{...}}). func parseCardCredential(header string) (cardCredential, error) { raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(header)) if err != nil { @@ -158,90 +190,177 @@ func parseCardCredential(header string) (cardCredential, error) { 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) +// ── SPT replay guard ──────────────────────────────────────────────────────── + +// sptReplayGuard rejects reuse of a Shared Payment Token. A token is reserved +// for the duration of a request and either consumed (kept blocked for the TTL) +// on a captured charge or released (unblocked) when the charge does not land, +// so transient failures can be retried with the same token. +type sptReplayGuard struct { + mu sync.Mutex + seen map[string]time.Time + ttl time.Duration +} + +func newSPTReplayGuard(ttl time.Duration) *sptReplayGuard { + return &sptReplayGuard{seen: make(map[string]time.Time), ttl: ttl} +} + +// tryReserve records the token as in-flight and returns false if it is already +// reserved or recently consumed. +func (g *sptReplayGuard) tryReserve(spt string) bool { + now := time.Now() + g.mu.Lock() + defer g.mu.Unlock() + for k, t := range g.seen { + if now.Sub(t) > g.ttl { + delete(g.seen, k) + } + } + if _, exists := g.seen[spt]; exists { + return false + } + g.seen[spt] = now + return true +} + +// release unblocks a token so it can be retried (charge did not land). +func (g *sptReplayGuard) release(spt string) { + g.mu.Lock() + delete(g.seen, spt) + g.mu.Unlock() +} -// stripeCardSettler implements cardSettleFunc against the Stripe -// PaymentIntents API, adapted from github.com/cp0x-org/mppx/stripe. -type stripeCardSettler struct { +// consume keeps a token blocked for the TTL after a successful capture. +func (g *sptReplayGuard) consume(spt string) { + g.mu.Lock() + g.seen[spt] = time.Now() + g.mu.Unlock() +} + +// ── Stripe gateway ────────────────────────────────────────────────────────── + +// cardGateway is the two-phase card settlement seam: authorize holds funds, +// capture takes them after the upstream serves successfully, cancel releases +// the hold on failure. Implementations must be safe to call on the request +// path (card settlement is synchronous and online). +type cardGateway interface { + authorize(ctx context.Context, card *CardRoute, amountMinorUnits, currency string, cred cardCredential) (paymentIntentID string, err error) + capture(ctx context.Context, card *CardRoute, paymentIntentID string) error + cancel(ctx context.Context, card *CardRoute, paymentIntentID string) error +} + +// stripeCardGateway implements cardGateway against the Stripe PaymentIntents +// API (manual capture), adapted from github.com/cp0x-org/mppx/stripe. +type stripeCardGateway 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. + baseURL string + // secretKey returns the seller's Stripe secret key. secretKey func() string } -func newStripeCardSettler() *stripeCardSettler { - return &stripeCardSettler{ - httpClient: &http.Client{Timeout: 20 * time.Second}, +func newStripeCardGateway() *stripeCardGateway { + return &stripeCardGateway{ + httpClient: &http.Client{Timeout: cardStripeTimeout}, + baseURL: stripeAPIBase, 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() +// defaultCardGateway / defaultSPTGuard are the package defaults used by +// serveCardGated. Kept as package vars (not Verifier fields) so the card path +// does not disturb the Verifier constructor; serveCardGated takes both so tests +// can inject fakes. +var ( + defaultCardGateway cardGateway = newStripeCardGateway() + defaultSPTGuard = newSPTReplayGuard(sptReplayTTL) +) -// 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 { +// buildAuthorizeForm is the form body for a manual-capture Stripe PaymentIntent +// create+confirm (the authorization). Split out for unit testing. +func buildAuthorizeForm(amountMinorUnits, currency, spt string) url.Values { form := url.Values{} form.Set("amount", amountMinorUnits) form.Set("currency", currency) form.Set("confirm", "true") + form.Set("capture_method", "manual") 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) { +func (s *stripeCardGateway) authorize(ctx context.Context, _ *CardRoute, amountMinorUnits, currency string, cred cardCredential) (string, error) { + id, status, err := s.do(ctx, s.baseURL+"/payment_intents", buildAuthorizeForm(amountMinorUnits, currency, cred.SPT), "obol_auth_"+cred.SPT) + if err != nil { + return "", err + } + // Manual capture + confirm: a successful authorization yields + // requires_capture (funds held, not taken). Accept succeeded defensively. + switch status { + case "requires_capture", "succeeded": + return id, nil + case "requires_action": + return "", errors.New("stripe PaymentIntent requires action (3DS) — not supported for machine payments") + default: + return "", fmt.Errorf("stripe authorize status: %s", status) + } +} + +func (s *stripeCardGateway) capture(ctx context.Context, _ *CardRoute, paymentIntentID string) error { + _, status, err := s.do(ctx, s.baseURL+"/payment_intents/"+url.PathEscape(paymentIntentID)+"/capture", url.Values{}, "obol_cap_"+paymentIntentID) + if err != nil { + return err + } + if status != "succeeded" { + return fmt.Errorf("stripe capture status: %s", status) + } + return nil +} + +func (s *stripeCardGateway) cancel(ctx context.Context, _ *CardRoute, paymentIntentID string) error { + _, _, err := s.do(ctx, s.baseURL+"/payment_intents/"+url.PathEscape(paymentIntentID)+"/cancel", url.Values{}, "") + return err +} + +// do issues a form-encoded POST to Stripe and returns the PaymentIntent id and +// status. Stripe uses HTTP Basic with the secret key as the username. +func (s *stripeCardGateway) do(ctx context.Context, endpoint string, form url.Values, idempotencyKey string) (id, status string, err error) { key := s.secretKey() if key == "" { - return "", errors.New("stripe secret key not configured (STRIPE_SECRET_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())) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) if err != nil { - return "", fmt.Errorf("build stripe request: %w", err) + 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) + if idempotencyKey != "" { + req.Header.Set("Idempotency-Key", idempotencyKey) + } resp, err := s.httpClient.Do(req) if err != nil { - return "", fmt.Errorf("stripe request failed: %w", err) + 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) + return "", "", fmt.Errorf("stripe API 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) + 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 + return body.ID, body.Status, nil } -// cardReceiptJSON builds the X-PAYMENT-RESPONSE body surfaced to the buyer -// after a successful card charge. +// cardReceiptJSON builds the X-PAYMENT-RESPONSE body surfaced to the buyer after +// a captured card charge. func cardReceiptJSON(reference string) []byte { b, err := json.Marshal(map[string]string{ "method": cardNetworkStripe, @@ -254,10 +373,26 @@ func cardReceiptJSON(reference string) []byte { return b } +func detachedCardContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), cardStripeTimeout) +} + +// cancelCardHold releases an authorized PaymentIntent and logs a failure. An +// uncancelled hold auto-expires at Stripe, but a swallowed error would leave no +// operator trail, so cancel failures are logged rather than ignored. +func cancelCardHold(gw cardGateway, rule *RouteRule, paymentIntentID string) { + ctx, cancel := detachedCardContext() + defer cancel() + if err := gw.cancel(ctx, rule.Card, paymentIntentID); err != nil { + log.Printf("x402-card: cancel authorization %s for %s/%s failed: %v", paymentIntentID, rule.OfferNamespace, rule.OfferName, err) + } +} + // 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. +// invoked from Verifier.HandleProxy when the matched route is a card route. It +// authorizes the buyer's SPT, proxies on a successful authorization, then +// captures after a <400 upstream response (cancelling the hold otherwise). Uses +// the JSON 402 (no HTML page). proxy is the already-built upstream handler. func (v *Verifier) serveCardGated( w http.ResponseWriter, r *http.Request, @@ -265,7 +400,8 @@ func (v *Verifier) serveCardGated( requirement x402types.PaymentRequirements, extensions map[string]any, proxy http.Handler, - settle cardSettleFunc, + gw cardGateway, + guard *sptReplayGuard, ) { reqs := []x402types.PaymentRequirements{requirement} @@ -282,15 +418,69 @@ func (v *Verifier) serveCardGated( return } + // Replay defense: a Shared Payment Token is single-use. + if !guard.tryReserve(cred.SPT) { + log.Printf("x402-card: replayed SPT rejected for %s/%s", rule.OfferNamespace, rule.OfferName) + sendPaymentRequiredJSON(w, r, reqs, extensions) + return + } + currency, _ := requirement.Extra["currency"].(string) - reference, err := settle(r.Context(), rule.Card, requirement.Amount, currency, cred) + + authCtx, cancelAuth := detachedCardContext() + paymentIntentID, err := gw.authorize(authCtx, rule.Card, requirement.Amount, currency, cred) + cancelAuth() if err != nil { - log.Printf("x402-card: settle failed for %s/%s: %v", rule.OfferNamespace, rule.OfferName, err) + // Authorization failed — buyer not charged; allow a retry with the SPT. + guard.release(cred.SPT) + log.Printf("x402-card: authorize 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) + // Authorized — wire capture-after-success / cancel-on-failure around the + // upstream via the shared settlementInterceptor. + interceptor := &settlementInterceptor{ + w: w, + settleFunc: func() bool { + cctx, cc := detachedCardContext() + defer cc() + if capErr := gw.capture(cctx, rule.Card, paymentIntentID); capErr != nil { + log.Printf("x402-card: capture failed for %s/%s: %v", rule.OfferNamespace, rule.OfferName, capErr) + // Release the authorization hold and unblock the SPT. + cancelCardHold(gw, rule, paymentIntentID) + guard.release(cred.SPT) + http.Error(w, "card capture failed", http.StatusBadGateway) + return false + } + guard.consume(cred.SPT) + w.Header().Set("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(cardReceiptJSON(paymentIntentID))) + return true + }, + onFailure: func(statusCode int) { + // Upstream failed — cancel the hold; buyer is not charged. + cancelCardHold(gw, rule, paymentIntentID) + guard.release(cred.SPT) + log.Printf("x402-card: upstream returned %d for %s/%s, authorization cancelled", statusCode, rule.OfferNamespace, rule.OfferName) + }, + } + + // Defensive reconcile: settleFunc/onFailure only fire from the + // interceptor's WriteHeader. If the upstream handler panics or returns + // without ever writing a response (committed stays false), neither runs — + // cancel the hold and release the SPT so the buyer is not left with funds + // authorized for a request that was never served. Re-panic to preserve the + // server's own panic handling (e.g. http.ErrAbortHandler). + defer func() { + rec := recover() + if !interceptor.committed { + cancelCardHold(gw, rule, paymentIntentID) + guard.release(cred.SPT) + log.Printf("x402-card: upstream produced no response for %s/%s, authorization cancelled", rule.OfferNamespace, rule.OfferName) + } + if rec != nil { + panic(rec) + } + }() + proxy.ServeHTTP(interceptor, r) } diff --git a/internal/x402/card_test.go b/internal/x402/card_test.go index 649bad03..e65b4062 100644 --- a/internal/x402/card_test.go +++ b/internal/x402/card_test.go @@ -7,7 +7,10 @@ import ( "io" "net/http" "net/http/httptest" + "strings" + "sync" "testing" + "time" ) func cardTestRule() *RouteRule { @@ -20,73 +23,79 @@ func cardTestRule() *RouteRule { Provider: "stripe", Account: "acct_test123", Currency: "usd", + Decimals: 2, NetworkID: "stripenet_abc", }, } } +func cardCredHeader(spt string) string { + b, _ := json.Marshal(map[string]string{"spt": spt}) + return base64.StdEncoding.EncodeToString(b) +} + +func TestCurrencyMinorUnits(t *testing.T) { + cases := map[string]int{"usd": 2, "USD": 2, "eur": 2, "jpy": 0, "krw": 0, "bhd": 3, "kwd": 3, "zzz": 2, "": 2} + for in, want := range cases { + if got := currencyMinorUnits(in); got != want { + t.Errorf("currencyMinorUnits(%q) = %d, want %d", in, got, want) + } + } +} + 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.Scheme != cardScheme || req.Network != cardNetworkStripe { + t.Errorf("scheme/network = %q/%q", req.Scheme, req.Network) } 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" { + if req.Amount != "1" { // "0.01" usd (2 decimals) -> 1 cent 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) + t.Errorf("asset = %q, want empty", req.Asset) } - if req.Extra["method"] != cardNetworkStripe { - t.Errorf("extra.method = %v, want stripe", req.Extra["method"]) + if req.Extra["currency"] != "usd" || req.Extra["networkId"] != "stripenet_abc" { + t.Errorf("extra = %v", req.Extra) } - 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) +func TestBuildCardRequirement_NonTwoDecimalCurrency(t *testing.T) { + rule := &RouteRule{Price: "100", Card: &CardRoute{Account: "acct_x", Currency: "jpy"}} + req := buildCardRequirement(rule) + // jpy has 0 minor-unit decimals: ¥100 -> amount "100". + if req.Amount != "100" { + t.Errorf("jpy amount = %q, want 100", req.Amount) + } + if req.Extra["decimals"] != 0 { + t.Errorf("jpy decimals = %v, want 0", req.Extra["decimals"]) } +} - t.Run("bare payload", func(t *testing.T) { +func TestParseCardCredential(t *testing.T) { + b64 := func(v any) string { b, _ := json.Marshal(v); return base64.StdEncoding.EncodeToString(b) } + + t.Run("bare", 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) + if err != nil || cred.SPT != "spt_abc" || cred.ExternalID != "e1" { + t.Fatalf("got %+v err=%v", cred, err) } }) - - t.Run("wrapped payload", func(t *testing.T) { + t.Run("wrapped", 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!!!"}, + for _, bad := range []struct{ name, header string }{ + {"bad base64", "!!!"}, {"missing spt", b64(map[string]string{"externalId": "e1"})}, {"wrong prefix", b64(map[string]string{"spt": "tok_abc"})}, } { @@ -98,15 +107,14 @@ func TestParseCardCredential(t *testing.T) { } } -func TestBuildPaymentIntentForm(t *testing.T) { - form := buildPaymentIntentForm("1", "usd", "spt_abc") +func TestBuildAuthorizeForm(t *testing.T) { + form := buildAuthorizeForm("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", + "amount": "1", + "currency": "usd", + "confirm": "true", + "capture_method": "manual", + "shared_payment_granted_token": "spt_abc", } for k, v := range want { if form.Get(k) != v { @@ -115,98 +123,267 @@ func TestBuildPaymentIntentForm(t *testing.T) { } } -func TestServeCardGated_NoPayment402(t *testing.T) { - rule := cardTestRule() - req := buildCardRequirement(rule) - proxied := false - proxy := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { proxied = true }) +func TestSPTReplayGuard(t *testing.T) { + g := newSPTReplayGuard(time.Hour) + if !g.tryReserve("spt_a") { + t.Fatal("first reserve should succeed") + } + if g.tryReserve("spt_a") { + t.Fatal("second reserve of in-flight token must fail") + } + g.release("spt_a") + if !g.tryReserve("spt_a") { + t.Fatal("after release, reserve should succeed again") + } + g.consume("spt_a") + if g.tryReserve("spt_a") { + t.Fatal("consumed token must stay blocked") + } + // TTL expiry: a guard with a 0 TTL forgets immediately. + g0 := newSPTReplayGuard(0) + g0.consume("spt_b") + if !g0.tryReserve("spt_b") { + t.Fatal("token past TTL should be reservable") + } +} - r := httptest.NewRequest(http.MethodPost, "/services/card-foo/x", nil) - w := httptest.NewRecorder() +// ── stripeCardGateway against a mock Stripe server ────────────────────────── - (&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 - }) +func TestStripeCardGateway_Lifecycle(t *testing.T) { + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + if !strings.HasPrefix(r.Header.Get("Authorization"), "Basic ") { + t.Errorf("missing Basic auth on %s", r.URL.Path) + } + _ = r.ParseForm() + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/capture"): + _, _ = io.WriteString(w, `{"id":"pi_x","status":"succeeded"}`) + case strings.HasSuffix(r.URL.Path, "/cancel"): + _, _ = io.WriteString(w, `{"id":"pi_x","status":"canceled"}`) + default: // authorize + if r.FormValue("capture_method") != "manual" { + t.Errorf("authorize capture_method = %q, want manual", r.FormValue("capture_method")) + } + if r.FormValue("shared_payment_granted_token") != "spt_live" { + t.Errorf("authorize spt = %q", r.FormValue("shared_payment_granted_token")) + } + _, _ = io.WriteString(w, `{"id":"pi_x","status":"requires_capture"}`) + } + })) + defer srv.Close() - if w.Code != http.StatusPaymentRequired { - t.Fatalf("status = %d, want 402", w.Code) + gw := &stripeCardGateway{httpClient: srv.Client(), baseURL: srv.URL, secretKey: func() string { return "sk_test" }} + ctx := context.Background() + + id, err := gw.authorize(ctx, nil, "100", "usd", cardCredential{SPT: "spt_live"}) + if err != nil || id != "pi_x" { + t.Fatalf("authorize id=%q err=%v", id, err) } - if proxied { - t.Error("upstream must not be proxied on 402") + if err := gw.capture(ctx, nil, id); err != nil { + t.Fatalf("capture: %v", err) } - var body struct { - Accepts []struct { - Scheme string `json:"scheme"` - } `json:"accepts"` + if err := gw.cancel(ctx, nil, id); err != nil { + t.Fatalf("cancel: %v", err) } - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("decode 402 body: %v", err) + if len(paths) != 3 { + t.Errorf("expected 3 Stripe calls, got %v", paths) } - if len(body.Accepts) != 1 || body.Accepts[0].Scheme != cardScheme { - t.Errorf("402 accepts = %+v, want one card entry", body.Accepts) +} + +func TestStripeCardGateway_NoKey(t *testing.T) { + gw := &stripeCardGateway{httpClient: http.DefaultClient, baseURL: stripeAPIBase, secretKey: func() string { return "" }} + if _, err := gw.authorize(context.Background(), nil, "1", "usd", cardCredential{SPT: "spt_a"}); err == nil { + t.Fatal("expected error when secret key unset") } } -func TestServeCardGated_PaidProxies(t *testing.T) { - rule := cardTestRule() - req := buildCardRequirement(rule) - proxy := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +func TestStripeCardGateway_AuthorizeRequiresAction(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"id":"pi_y","status":"requires_action"}`) + })) + defer srv.Close() + gw := &stripeCardGateway{httpClient: srv.Client(), baseURL: srv.URL, secretKey: func() string { return "sk_test" }} + if _, err := gw.authorize(context.Background(), nil, "1", "usd", cardCredential{SPT: "spt_a"}); err == nil { + t.Fatal("requires_action must be an error (3DS not supported)") + } +} + +// ── serveCardGated with a fake gateway ────────────────────────────────────── + +type fakeGateway struct { + mu sync.Mutex + authErr error + capErr error + authCalls int + captured []string + canceled []string + pi string +} + +func (f *fakeGateway) authorize(_ context.Context, _ *CardRoute, _, _ string, _ cardCredential) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.authCalls++ + if f.authErr != nil { + return "", f.authErr + } + return f.pi, nil +} + +func (f *fakeGateway) capture(_ context.Context, _ *CardRoute, pi string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.capErr != nil { + return f.capErr + } + f.captured = append(f.captured, pi) + return nil +} + +func (f *fakeGateway) cancel(_ context.Context, _ *CardRoute, pi string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.canceled = append(f.canceled, pi) + return nil +} + +func okProxy() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = io.WriteString(w, "upstream-ok") }) +} + +func failProxy() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + }) +} - cred, _ := json.Marshal(map[string]string{"spt": "spt_live", "externalId": "e9"}) +func gateOnce(gw cardGateway, guard *sptReplayGuard, sptHeader string, proxy http.Handler) *httptest.ResponseRecorder { + rule := cardTestRule() + req := buildCardRequirement(rule) r := httptest.NewRequest(http.MethodPost, "/services/card-foo/x", nil) - r.Header.Set("X-PAYMENT", base64.StdEncoding.EncodeToString(cred)) + if sptHeader != "" { + r.Header.Set("X-PAYMENT", sptHeader) + } w := httptest.NewRecorder() + (&Verifier{}).serveCardGated(w, r, rule, req, nil, proxy, gw, guard) + return w +} - 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 +func TestServeCardGated_NoPayment402(t *testing.T) { + gw := &fakeGateway{pi: "pi_1"} + w := gateOnce(gw, newSPTReplayGuard(time.Hour), "", okProxy()) + if w.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want 402", w.Code) } + if gw.authCalls != 0 { + t.Error("authorize must not be called without a credential") + } +} - (&Verifier{}).serveCardGated(w, r, rule, req, nil, proxy, settle) +func TestServeCardGated_PaidAuthorizeCaptureProxy(t *testing.T) { + gw := &fakeGateway{pi: "pi_1"} + guard := newSPTReplayGuard(time.Hour) + w := gateOnce(gw, guard, cardCredHeader("spt_a"), okProxy()) 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()) + t.Fatalf("status=%d body=%q", 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) + if gw.authCalls != 1 || len(gw.captured) != 1 || gw.captured[0] != "pi_1" { + t.Fatalf("auth=%d captured=%v", gw.authCalls, gw.captured) } - // Receipt header references the PaymentIntent. - hdr := w.Header().Get("X-PAYMENT-RESPONSE") - if hdr == "" { - t.Fatal("missing X-PAYMENT-RESPONSE header") + if len(gw.canceled) != 0 { + t.Errorf("must not cancel on success: %v", gw.canceled) } + hdr := w.Header().Get("X-PAYMENT-RESPONSE") 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) + if receipt["reference"] != "pi_1" { + t.Errorf("receipt = %v, want reference pi_1", receipt) + } + // SPT now consumed: a replay is rejected and does not re-authorize. + w2 := gateOnce(gw, guard, cardCredHeader("spt_a"), okProxy()) + if w2.Code != http.StatusPaymentRequired { + t.Errorf("replay status = %d, want 402", w2.Code) + } + if gw.authCalls != 1 { + t.Errorf("replay must not re-authorize: authCalls=%d", gw.authCalls) } } -func TestServeCardGated_SettleFailure402(t *testing.T) { - rule := cardTestRule() - req := buildCardRequirement(rule) - proxied := false - proxy := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { proxied = true }) +func TestServeCardGated_AuthorizeFailure402(t *testing.T) { + gw := &fakeGateway{authErr: io.ErrUnexpectedEOF} + guard := newSPTReplayGuard(time.Hour) + w := gateOnce(gw, guard, cardCredHeader("spt_a"), okProxy()) + if w.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want 402", w.Code) + } + if len(gw.captured) != 0 { + t.Error("must not capture when authorize fails") + } + // Authorization failure releases the SPT for retry. + if !guard.tryReserve("spt_a") { + t.Error("SPT should be released after authorize failure") + } +} - 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() +func TestServeCardGated_UpstreamFailureCancels(t *testing.T) { + gw := &fakeGateway{pi: "pi_2"} + guard := newSPTReplayGuard(time.Hour) + w := gateOnce(gw, guard, cardCredHeader("spt_a"), failProxy()) + if w.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 passthrough", w.Code) + } + if len(gw.captured) != 0 { + t.Errorf("must not capture on upstream failure: %v", gw.captured) + } + if len(gw.canceled) != 1 || gw.canceled[0] != "pi_2" { + t.Errorf("must cancel authorization on upstream failure: %v", gw.canceled) + } + if !guard.tryReserve("spt_a") { + t.Error("SPT should be released after upstream failure") + } +} - (&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 - }) +func TestServeCardGated_UpstreamPanicCancels(t *testing.T) { + gw := &fakeGateway{pi: "pi_panic"} + guard := newSPTReplayGuard(time.Hour) + panicProxy := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("upstream blew up") }) - if w.Code != http.StatusPaymentRequired { - t.Fatalf("status = %d, want 402 on settle failure", w.Code) + // serveCardGated re-panics to preserve server panic handling; recover here. + func() { + defer func() { _ = recover() }() + gateOnce(gw, guard, cardCredHeader("spt_a"), panicProxy) + }() + + if len(gw.captured) != 0 { + t.Errorf("must not capture when upstream panics: %v", gw.captured) + } + if len(gw.canceled) != 1 || gw.canceled[0] != "pi_panic" { + t.Errorf("panic must cancel the authorization hold: %v", gw.canceled) + } + if !guard.tryReserve("spt_a") { + t.Error("SPT should be released after a panic") + } +} + +func TestServeCardGated_CaptureFailure(t *testing.T) { + gw := &fakeGateway{pi: "pi_3", capErr: io.ErrUnexpectedEOF} + guard := newSPTReplayGuard(time.Hour) + w := gateOnce(gw, guard, cardCredHeader("spt_a"), okProxy()) + if w.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502 on capture failure", w.Code) + } + if len(gw.canceled) != 1 || gw.canceled[0] != "pi_3" { + t.Errorf("capture failure must cancel the hold: %v", gw.canceled) } - if proxied { - t.Error("upstream must not be proxied when settlement fails") + if !guard.tryReserve("spt_a") { + t.Error("SPT should be released after capture failure") } } diff --git a/internal/x402/serviceoffer_source.go b/internal/x402/serviceoffer_source.go index b822e2c2..30dfbc1f 100644 --- a/internal/x402/serviceoffer_source.go +++ b/internal/x402/serviceoffer_source.go @@ -180,6 +180,30 @@ func routeRuleFromOffer(offer *monetizeapi.ServiceOffer, upstreamAuth string) (R OfferName: offer.Name, } + // MPP credit-card offers carry off-chain Stripe settlement terms instead + // of the crypto payTo/network/asset. Populate the card route so the + // verifier gates this offer through serveCardGated (matchPaidRouteFull / + // HandleProxy dispatch on rule.IsCard()). + if strings.EqualFold(offer.Spec.Payment.Method, "card") && offer.Spec.Payment.Card != nil { + c := offer.Spec.Payment.Card + currency := strings.ToLower(strings.TrimSpace(c.Currency)) + if currency == "" { + currency = defaultCardCurrency + } + provider := c.Provider + if provider == "" { + provider = cardNetworkStripe + } + rule.Card = &CardRoute{ + Provider: provider, + Account: c.Account, + Currency: currency, + Decimals: currencyMinorUnits(currency), + NetworkID: c.NetworkID, + PaymentMethodTypes: append([]string(nil), c.PaymentMethodTypes...), + } + } + if offer.IsAgent() && offer.Status.AgentResolution != nil { res := offer.Status.AgentResolution rule.AgentModel = res.Model diff --git a/internal/x402/serviceoffer_source_test.go b/internal/x402/serviceoffer_source_test.go index 8390d682..17208451 100644 --- a/internal/x402/serviceoffer_source_test.go +++ b/internal/x402/serviceoffer_source_test.go @@ -200,6 +200,68 @@ func TestRouteRuleFromOffer_AgentResolutionAdvertisesRuntimeModelSkills(t *testi } } +func TestRouteRuleFromOffer_CardPaymentPopulatesCardRoute(t *testing.T) { + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "card-svc", Namespace: "shop"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "http", + Upstream: monetizeapi.ServiceOfferUpstream{Service: "api", Namespace: "shop", Port: 8080}, + Payment: monetizeapi.ServiceOfferPayment{ + Method: "card", + Card: &monetizeapi.ServiceOfferCardPayment{ + Provider: "stripe", + Account: "acct_shop1", + Currency: "jpy", + NetworkID: "stripenet_1", + }, + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "100"}, + }, + }, + } + + route, err := routeRuleFromOffer(offer, "") + if err != nil { + t.Fatalf("routeRuleFromOffer: %v", err) + } + if !route.IsCard() { + t.Fatal("expected a card route") + } + if route.Card.Account != "acct_shop1" || route.Card.Provider != "stripe" { + t.Errorf("card = %+v", route.Card) + } + // jpy currency derives 0 minor-unit decimals. + if route.Card.Currency != "jpy" || route.Card.Decimals != 0 { + t.Errorf("currency/decimals = %q/%d, want jpy/0", route.Card.Currency, route.Card.Decimals) + } + if route.Card.NetworkID != "stripenet_1" { + t.Errorf("networkId = %q, want stripenet_1", route.Card.NetworkID) + } + // The built requirement uses jpy minor units: ¥100 -> "100". + if amt := buildCardRequirement(&route).Amount; amt != "100" { + t.Errorf("card requirement amount = %q, want 100", amt) + } + + // A crypto offer must NOT produce a card route. + cryptoOffer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "n"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "http", + Payment: monetizeapi.ServiceOfferPayment{ + Network: "base", + PayTo: "0x1111111111111111111111111111111111111111", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.01"}, + }, + }, + } + cr, err := routeRuleFromOffer(cryptoOffer, "") + if err != nil { + t.Fatalf("routeRuleFromOffer(crypto): %v", err) + } + if cr.IsCard() { + t.Error("crypto offer must not produce a card route") + } +} + func TestRoutesFromStore_AgentOfferInjectsHermesAPIKey(t *testing.T) { items := []any{ mustOfferObject(t, monetizeapi.ServiceOffer{ diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index 0c75cad0..e080164f 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -234,10 +234,10 @@ 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. + // MPP credit-card offers gate through Stripe (authorize -> capture/cancel) + // instead of the x402 facilitator ForwardAuth path. if rule.IsCard() { - v.serveCardGated(w, r, rule, requirement, extensions, proxy, defaultCardSettler.settle) + v.serveCardGated(w, r, rule, requirement, extensions, proxy, defaultCardGateway, defaultSPTGuard) return } @@ -320,8 +320,8 @@ 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. + // 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 }