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 97a09d7f..843842bb 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) A tunnel exposes your stack to the public internet so buyers can discover and diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 6e1287c7..759dfdbe 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -16,6 +16,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "regexp" "runtime" "strconv" "strings" @@ -86,6 +87,67 @@ func payToFlag(usage string) *cli.StringFlag { } } +// Payment-method selector values for the --pay-with flag. +const ( + payMethodCrypto = "crypto" + payMethodCard = "card" +) + +var ( + // stripeAccountRe matches a Stripe account id (e.g. acct_1A2b3C4d). + stripeAccountRe = regexp.MustCompile(`^acct_[A-Za-z0-9]+$`) + // currencyRe matches a lower-case ISO-4217 currency code (e.g. usd). + currencyRe = regexp.MustCompile(`^[a-z]{3}$`) +) + +// normalizePayWith lower-cases/trims the --pay-with value and defaults an +// empty value to crypto so existing flag-free invocations are unchanged. +func normalizePayWith(v string) string { + v = strings.ToLower(strings.TrimSpace(v)) + if v == "" { + return payMethodCrypto + } + return v +} + +// resolveCardPayment validates the card flags and returns the +// spec.payment map for an MPP credit-card (Stripe) ServiceOffer. It is the +// card analog of the crypto wallet/chain/asset resolution in the sell +// actions: instead of a chain + 0x payTo it emits method=card plus a card +// block carrying the Stripe destination account and currency. +func resolveCardPayment(cmd *cli.Command, price map[string]any) (map[string]any, error) { + account := strings.TrimSpace(cmd.String("stripe-account")) + if account == "" { + return nil, fmt.Errorf("--stripe-account is required with --pay-with card (the acct_... that receives card funds)") + } + if !stripeAccountRe.MatchString(account) { + return nil, fmt.Errorf("invalid --stripe-account %q: expected a Stripe account id like acct_1A2b3C4d", account) + } + currency := strings.ToLower(strings.TrimSpace(cmd.String("card-currency"))) + if currency == "" { + currency = "usd" + } + 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, + } + // Stripe MPP uses a profile id (profile_... / profile_test_...) so card + // clients can mint a Shared Payment Token. + if profileID := strings.TrimSpace(cmd.String("stripe-profile-id")); profileID != "" { + card["profileId"] = profileID + } + return map[string]any{ + "method": payMethodCard, + "card": card, + "maxTimeoutSeconds": cmd.Int("max-timeout"), + "price": price, + }, nil +} + // --------------------------------------------------------------------------- // sell inference — start a local x402 gateway for LLM inference // --------------------------------------------------------------------------- @@ -706,6 +768,25 @@ Examples: Usage: "Target namespace for the ServiceOffer", Value: "default", }, + &cli.StringFlag{ + Name: "pay-with", + Usage: "Payment method: 'crypto' (x402 on-chain stablecoin, default) or 'card' (MPP Stripe credit card)", + Value: payMethodCrypto, + }, + &cli.StringFlag{ + Name: "stripe-account", + Usage: "Stripe destination account id (acct_...) that receives card funds — required with --pay-with card (card analog of --pay-to)", + }, + &cli.StringFlag{ + Name: "card-currency", + Usage: "ISO-4217 currency for card charges", + Value: "usd", + }, + &cli.StringFlag{ + Name: "stripe-profile-id", + Usage: "Stripe profile id (profile_... or profile_test_...) advertised in the MPP challenge", + Sources: cli.EnvVars("STRIPE_PROFILE_ID"), + }, &cli.StringFlag{ Name: "upstream", Usage: "Upstream service name", @@ -840,32 +921,17 @@ Examples: return err } - // Auto-discover wallet from remote-signer if not set. - wallet := cmd.String("pay-to") - if wallet == "" { - if resolved, err := hermes.ResolveWalletAddress(cfg); err == nil { - wallet = resolved - u.Infof("Using wallet from remote-signer: %s", wallet) - } else if u.IsTTY() { - var inputErr error - wallet, inputErr = u.Input("Wallet address (payment recipient)", "") - if inputErr != nil || wallet == "" { - return fmt.Errorf("recipient required: use --pay-to or set X402_WALLET") - } - } else { - return fmt.Errorf("recipient required: use --pay-to or set X402_WALLET") - } - } - if err := x402verifier.ValidateWallet(wallet); err != nil { - return err - } - - // Ensure the x402-verifier CA bundle is populated so TLS verification of - // the facilitator works. This is a no-op if already populated. Non-fatal. - x402verifier.PopulateCABundle(cfg) - ns := cmd.String("namespace") + payWith := normalizePayWith(cmd.String("pay-with")) + if payWith != payMethodCrypto && payWith != payMethodCard { + return fmt.Errorf("--pay-with must be %q or %q, got %q", payMethodCrypto, payMethodCard, cmd.String("pay-with")) + } + isCard := payWith == payMethodCard + // wallet is the crypto payTo recipient; resolved in the crypto + // branch below and left empty for card offers. + var wallet string + if cmd.String("upstream") == "" { return fmt.Errorf("upstream service name required: use --upstream \n\n Example: obol sell http %s --upstream my-svc --port 8080 --pay-to 0x... --chain base-sepolia --price 0.001", name) } @@ -889,10 +955,59 @@ Examples: price["perHour"] = priceTable.PerHour } - chainName := cmd.String("chain") - assetTerms, err := resolveAssetTerms(cmd, &chainName) - if err != nil { - return err + // Resolve the payment block per the selected method. + var ( + payment map[string]any + assetTerms schemas.AssetTerms // crypto only; stays zero for card + ) + switch payWith { + case payMethodCard: + payment, err = resolveCardPayment(cmd, price) + if err != nil { + return err + } + u.Infof("Selling via credit card (Stripe account %s, %s)", + cmd.String("stripe-account"), strings.ToLower(cmd.String("card-currency"))) + default: // payMethodCrypto + // Auto-discover wallet from remote-signer if not set. + wallet = cmd.String("pay-to") + if wallet == "" { + if resolved, rerr := hermes.ResolveWalletAddress(cfg); rerr == nil { + wallet = resolved + u.Infof("Using wallet from remote-signer: %s", wallet) + } else if u.IsTTY() { + var inputErr error + wallet, inputErr = u.Input("Wallet address (payment recipient)", "") + if inputErr != nil || wallet == "" { + return fmt.Errorf("recipient required: use --pay-to or set X402_WALLET") + } + } else { + return fmt.Errorf("recipient required: use --pay-to or set X402_WALLET") + } + } + if err := x402verifier.ValidateWallet(wallet); err != nil { + return err + } + // Ensure the x402-verifier CA bundle is populated so TLS + // verification of the facilitator works. No-op if already + // populated. Non-fatal. + x402verifier.PopulateCABundle(cfg) + + chainName := cmd.String("chain") + assetTerms, err = resolveAssetTerms(cmd, &chainName) + if err != nil { + return err + } + payment = map[string]any{ + "scheme": "exact", + "network": chainName, + "payTo": wallet, + "maxTimeoutSeconds": cmd.Int("max-timeout"), + "price": price, + } + if !assetTerms.IsZero() { + payment["asset"] = assetTerms + } } spec := map[string]any{ @@ -903,16 +1018,7 @@ Examples: "port": cmd.Int("port"), "healthPath": cmd.String("health-path"), }, - "payment": map[string]any{ - "scheme": "exact", - "network": chainName, - "payTo": wallet, - "maxTimeoutSeconds": cmd.Int("max-timeout"), - "price": price, - }, - } - if !assetTerms.IsZero() { - spec["payment"].(map[string]any)["asset"] = assetTerms + "payment": payment, } if path := cmd.String("path"); path != "" { @@ -941,21 +1047,33 @@ Examples: prov.Framework, prov.MetricName, prov.MetricValue, prov.ParamCount) } - reg, registerEnabled, err := buildSellRegistrationConfig(name, sellRegistrationInput{ - NoRegister: cmd.Bool("no-register"), - Register: cmd.Bool("register"), - Name: cmd.String("register-name"), - Description: cmd.String("description"), - Image: cmd.String("register-image"), - Skills: cmd.StringSlice("register-skills"), - Domains: cmd.StringSlice("register-domains"), - MetadataPairs: cmd.StringSlice("register-metadata"), - }) - if err != nil { - return err - } - if registerEnabled { - spec["registration"] = reg + // ERC-8004 registration is an on-chain identity step and only + // applies to crypto offers. Card offers publish the payment-gated + // route without registration. + var registerEnabled bool + if isCard { + if cmd.Bool("register") { + return fmt.Errorf("ERC-8004 registration is not supported for --pay-with card yet; re-run with --no-register") + } + u.Info("Card offers are not ERC-8004 registered (no on-chain identity); publishing the payment-gated route only.") + } else { + reg, enabled, rerr := buildSellRegistrationConfig(name, sellRegistrationInput{ + NoRegister: cmd.Bool("no-register"), + Register: cmd.Bool("register"), + Name: cmd.String("register-name"), + Description: cmd.String("description"), + Image: cmd.String("register-image"), + Skills: cmd.StringSlice("register-skills"), + Domains: cmd.StringSlice("register-domains"), + MetadataPairs: cmd.StringSlice("register-metadata"), + }) + if rerr != nil { + return rerr + } + registerEnabled = enabled + if registerEnabled { + spec["registration"] = reg + } } // When registration is enabled, the serviceoffer-controller reads the diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index d363a232..25277ea0 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -285,16 +285,119 @@ 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", "stripe-profile-id", ) assertStringDefault(t, flags, "chain", "base") assertStringDefault(t, flags, "token", "USDC") assertStringDefault(t, flags, "namespace", "default") assertStringDefault(t, flags, "health-path", "/health") + assertStringDefault(t, flags, "pay-with", "crypto") + assertStringDefault(t, flags, "card-currency", "usd") assertIntDefault(t, flags, "port", 8080) assertIntDefault(t, flags, "max-timeout", 300) } +func TestNormalizePayWith(t *testing.T) { + cases := map[string]string{ + "": payMethodCrypto, + " ": payMethodCrypto, + "crypto": payMethodCrypto, + "CRYPTO": payMethodCrypto, + "card": payMethodCard, + " Card ": payMethodCard, + "unknown": "unknown", // passthrough; caller rejects + } + for in, want := range cases { + if got := normalizePayWith(in); got != want { + t.Errorf("normalizePayWith(%q) = %q, want %q", in, got, want) + } + } +} + +// runCardResolve builds a minimal cli.Command carrying the card flags, +// parses args, and returns resolveCardPayment's result. +func runCardResolve(t *testing.T, args ...string) (map[string]any, error) { + t.Helper() + var ( + out map[string]any + rerr error + ) + cmd := &cli.Command{ + Name: "http", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "pay-with", Value: payMethodCard}, + &cli.StringFlag{Name: "stripe-account"}, + &cli.StringFlag{Name: "card-currency", Value: "usd"}, + &cli.StringFlag{Name: "stripe-profile-id"}, + &cli.IntFlag{Name: "max-timeout", Value: 300}, + }, + Action: func(_ context.Context, c *cli.Command) error { + out, rerr = resolveCardPayment(c, map[string]any{"perRequest": "0.01"}) + return nil + }, + } + if err := cmd.Run(context.Background(), append([]string{"http"}, args...)); err != nil { + t.Fatalf("cmd.Run: %v", err) + } + return out, rerr +} + +func TestResolveCardPayment_Valid(t *testing.T) { + out, err := runCardResolve(t, "--stripe-account", "acct_1A2b3C4d", "--card-currency", "eur", "--stripe-profile-id", "profile_test_cli") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out["method"] != payMethodCard { + t.Errorf("method = %v, want card", out["method"]) + } + card, ok := out["card"].(map[string]any) + if !ok { + t.Fatalf("card block missing/not a map: %v", out["card"]) + } + if card["account"] != "acct_1A2b3C4d" { + t.Errorf("card.account = %v, want acct_1A2b3C4d", card["account"]) + } + if card["provider"] != "stripe" { + t.Errorf("card.provider = %v, want stripe", card["provider"]) + } + if card["currency"] != "eur" { + t.Errorf("card.currency = %v, want eur", card["currency"]) + } + if card["profileId"] != "profile_test_cli" { + t.Errorf("card.profileId = %v, want profile_test_cli", card["profileId"]) + } + if _, ok := out["price"].(map[string]any); !ok { + t.Errorf("price block missing: %v", out["price"]) + } + // payTo / network must NOT leak into a card payment. + if _, ok := out["payTo"]; ok { + t.Error("card payment must not contain payTo") + } + if _, ok := out["network"]; ok { + t.Error("card payment must not contain network") + } +} + +func TestResolveCardPayment_Invalid(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {"missing account", []string{"--card-currency", "usd"}}, + {"bad account prefix", []string{"--stripe-account", "0xdeadbeef"}}, + {"bad currency", []string{"--stripe-account", "acct_x1", "--card-currency", "US"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := runCardResolve(t, tc.args...) + if err == nil { + t.Fatalf("expected error for %s", tc.name) + } + }) + } +} + func TestBuildSellRegistrationConfig_DefaultEnabled(t *testing.T) { reg, enabled, err := buildSellRegistrationConfig("demo", sellRegistrationInput{}) if err != nil { diff --git a/go.mod b/go.mod index eb8589f5..0acf3caf 100644 --- a/go.mod +++ b/go.mod @@ -1,32 +1,33 @@ module github.com/ObolNetwork/obol-stack -go 1.25.1 +go 1.26 require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/cucumber/godog v0.15.1 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 - github.com/ethereum/go-ethereum v1.16.7 + github.com/ethereum/go-ethereum v1.17.3 github.com/google/go-sev-guest v0.14.1 github.com/google/go-tdx-guest v0.3.1 github.com/google/uuid v1.6.0 github.com/hf/nitrite v0.0.0-20241225144000-c2d5d3c4f303 github.com/hf/nsm v0.0.0-20220930140112-cd181bd646b9 - github.com/mattn/go-isatty v0.0.20 + github.com/mattn/go-isatty v0.0.22 github.com/modelcontextprotocol/go-sdk v1.3.0 github.com/prometheus/client_golang v1.19.1 github.com/prometheus/client_model v0.6.1 github.com/prometheus/common v0.55.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/shopspring/decimal v1.3.1 + github.com/tempoxyz/mpp-go v0.1.2 + github.com/tempoxyz/tempo-go v0.4.1 github.com/urfave/cli/v2 v2.27.5 github.com/urfave/cli/v3 v3.6.2 github.com/x402-foundation/x402/go v0.0.0-20260529172747-45d81d46e5bd - golang.org/x/crypto v0.46.0 - golang.org/x/net v0.48.0 - golang.org/x/sys v0.39.0 - golang.org/x/term v0.38.0 + golang.org/x/crypto v0.50.0 + golang.org/x/sys v0.43.0 + golang.org/x/term v0.42.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.34.1 k8s.io/apimachinery v0.34.1 @@ -48,19 +49,18 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/consensys/gnark-crypto v0.19.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect - github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect - github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect + github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect - github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect - github.com/ethereum/go-verkle v0.2.2 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect @@ -92,8 +92,8 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/redis/go-redis/v9 v9.19.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect github.com/spf13/cobra v1.9.1 // indirect @@ -105,16 +105,22 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/mod v0.30.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.39.0 // indirect + golang.org/x/tools v0.43.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 60ea8dfb..5a9d36de 100644 --- a/go.sum +++ b/go.sum @@ -8,12 +8,18 @@ github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDO github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0= github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -46,10 +52,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg= -github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= -github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= -github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= +github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= +github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= @@ -80,14 +84,12 @@ github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s= -github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= +github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= +github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= -github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo20U59UQ= -github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk= -github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= -github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= +github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q= +github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= @@ -101,8 +103,11 @@ github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqG github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= @@ -154,6 +159,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/graph-gophers/graphql-go v1.3.0 h1:Eb9x/q6MFpCLz7jBCiP/WTxjSDrYLR1QY41SORZyNJ0= github.com/graph-gophers/graphql-go v1.3.0/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= @@ -218,8 +227,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= @@ -242,8 +251,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= @@ -277,6 +284,8 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= +github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -316,6 +325,10 @@ github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jq github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/tempoxyz/mpp-go v0.1.2 h1:8Yo95CntHynHvBMco/Eub7unISBC1TGe3Ku0tAOjUOM= +github.com/tempoxyz/mpp-go v0.1.2/go.mod h1:tVrbCg1lo8PUk5+RU1VsRB4KBP+EFf83dBhiD0iclI8= +github.com/tempoxyz/tempo-go v0.4.1 h1:09upmr7C3YfIEAlwP7eOzQLEoBJR7gH93St8SGcg9BA= +github.com/tempoxyz/tempo-go v0.4.1/go.mod h1:PATi6I9r/0ZmmbLdFueqYywJ/odj8DhlwMbP93A60Rs= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= @@ -342,6 +355,22 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -353,46 +382,45 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -401,8 +429,8 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210105210202-9ed45478a130/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= diff --git a/internal/embed/embed_crd_test.go b/internal/embed/embed_crd_test.go index 9964b65d..d22d328a 100644 --- a/internal/embed/embed_crd_test.go +++ b/internal/embed/embed_crd_test.go @@ -241,6 +241,89 @@ func TestServiceOfferCRD_WalletValidation(t *testing.T) { } } +// TestServiceOfferCRD_CardPayment guards the MPP credit-card schema: the +// method discriminator, the card block (Stripe account/provider), and the +// per-method CEL validation rules that gate payTo/network/card.account. +func TestServiceOfferCRD_CardPayment(t *testing.T) { + data, err := ReadInfrastructureFile("base/templates/serviceoffer-crd.yaml") + if err != nil { + t.Fatalf("ReadInfrastructureFile: %v", err) + } + + crd := findDoc(multiDoc(data), "CustomResourceDefinition") + if crd == nil { + t.Fatal("no CRD document found") + } + + versions := nested(crd, "spec", "versions").([]any) + v0 := versions[0].(map[string]any) + payment := nested(v0, "schema", "openAPIV3Schema", "properties", "spec", + "properties", "payment").(map[string]any) + props := payment["properties"].(map[string]any) + + // method discriminator: enum crypto;card, default crypto. + method, ok := props["method"].(map[string]any) + if !ok { + t.Fatal("payment.method property missing") + } + if method["default"] != "crypto" { + t.Errorf("payment.method.default = %v, want crypto", method["default"]) + } + gotEnum := map[string]bool{} + for _, e := range method["enum"].([]any) { + gotEnum[e.(string)] = true + } + if !gotEnum["crypto"] || !gotEnum["card"] { + t.Errorf("payment.method.enum = %v, want crypto+card", method["enum"]) + } + + // card block: account pattern + provider enum. + card, ok := props["card"].(map[string]any) + if !ok { + t.Fatal("payment.card property missing") + } + cardProps := card["properties"].(map[string]any) + account := cardProps["account"].(map[string]any) + if account["pattern"] != "^acct_[A-Za-z0-9]+$" { + t.Errorf("payment.card.account.pattern = %v, want ^acct_[A-Za-z0-9]+$", account["pattern"]) + } + provider := cardProps["provider"].(map[string]any) + provEnum := map[string]bool{} + for _, e := range provider["enum"].([]any) { + provEnum[e.(string)] = true + } + if !provEnum["stripe"] { + t.Errorf("payment.card.provider.enum = %v, want stripe", provider["enum"]) + } + + // payTo must no longer be unconditionally required (card offers omit it); + // the per-method requirement is enforced by CEL instead. + for _, r := range nested(payment, "required").([]any) { + if r.(string) == "payTo" || r.(string) == "network" { + t.Errorf("payment.required must not list %q (now CEL-gated by method)", r) + } + } + + // CEL rules: payTo-when-crypto, network-when-crypto, + // card.account-when-card, and required Tempo fields when MPP is enabled. + rules, ok := payment["x-kubernetes-validations"].([]any) + if !ok { + t.Fatal("payment.x-kubernetes-validations missing") + } + if len(rules) != 4 { + t.Fatalf("payment x-kubernetes-validations count = %d, want 4", len(rules)) + } + joined := "" + for _, r := range rules { + joined += r.(map[string]any)["rule"].(string) + "\n" + } + for _, want := range []string{"self.payTo", "self.network", "self.card.account", "self.mpp.tempo.payTo", "self.mpp.tempo.asset"} { + if !strings.Contains(joined, want) { + t.Errorf("CEL rules missing reference to %q; got:\n%s", want, joined) + } + } +} + func TestRegistrationRequestCRD_Parses(t *testing.T) { data, err := ReadInfrastructureFile("base/templates/registrationrequest-crd.yaml") if err != nil { diff --git a/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml b/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml index 7b67e13a..29635c12 100644 --- a/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml +++ b/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml @@ -120,11 +120,25 @@ spec: pattern: ^/[a-zA-Z0-9/_.-]*$ type: string payment: + description: |- + ServiceOfferPayment describes how buyers pay for the offer. Two methods + are supported, selected by Method: + + - "crypto" (default): x402 on-chain stablecoin settlement. Network and + PayTo are required and PayTo must be a 0x EVM address. + - "card": an MPP credit-card method (Stripe stripe.charge). Card is + required; funds settle off-chain into the configured Stripe account + and Network/PayTo do not apply. + + The per-method required fields are enforced by the XValidation rules + below so the API server rejects malformed offers at admission time, + independent of the CLI. The CEL guards short-circuit on Method so the + 0x/account checks are only evaluated for the relevant method. properties: asset: description: |- Optional token metadata override for x402 settlement. When omitted, - the verifier uses the chain default asset. + the verifier uses the chain default asset. Crypto only. properties: address: description: ERC-20 contract address. @@ -152,24 +166,117 @@ spec: - permit2 type: string type: object + card: + description: Card payment terms. Required when method=card; ignored + otherwise. + properties: + account: + description: |- + Destination account that receives settled card funds. For Stripe this + is the connected/destination account id (e.g. "acct_1A2b3C4d5E6f7G"). + pattern: ^acct_[A-Za-z0-9]+$ + type: string + currency: + default: usd + description: ISO-4217 currency the card is charged in. Default + "usd". + pattern: ^[a-z]{3}$ + type: string + paymentMethodTypes: + description: |- + Accepted payment-method types advertised to the client. Defaults to + ["card","link"] at the gateway when empty. + items: + type: string + maxItems: 16 + type: array + profileId: + description: |- + Preferred Stripe profile id (profile_... or profile_test_...), surfaced + in the MPP challenge so clients know where to mint a Shared Payment Token. + type: string + provider: + default: stripe + description: |- + Card payment provider. Only "stripe" is supported today (MPP + stripe.charge via Shared Payment Tokens). + enum: + - stripe + type: string + type: object maxTimeoutSeconds: default: 300 description: 'Payment validity window in seconds (x402: maxTimeoutSeconds).' format: int64 type: integer + method: + default: crypto + description: |- + Payment method. "crypto" gates with x402 on-chain stablecoin + settlement (default; preserves existing behavior). "card" gates with + an MPP credit-card method (Stripe) that settles off-chain into + spec.payment.card.account. + enum: + - crypto + - card + type: string + mpp: + description: |- + MPP advertises optional HTTP-auth payment rails alongside the primary + method. Stripe SPT is configured in Card; Tempo is explicit here so + operators can see when a route advertises direct Tempo payment. + properties: + tempo: + description: |- + ServiceOfferTempoMPPPayment advertises a Tempo MPP payment rail. V1 supports + pull transaction credentials only so the verifier can broadcast after the + protected upstream succeeds. + properties: + asset: + description: Tempo token contract address. + pattern: ^0x[0-9a-fA-F]{40}$ + type: string + chainId: + description: Optional Tempo chain id for challenge binding. + format: int64 + type: integer + decimals: + description: Token decimals. Defaults to 6 when omitted. + format: int64 + maximum: 255 + minimum: 0 + type: integer + network: + description: Optional human-readable network label, used + for display only. + type: string + payTo: + description: Recipient Tempo/EVM address for the primary + transfer. + pattern: ^0x[0-9a-fA-F]{40}$ + type: string + type: object + type: object network: description: |- Chain identifier for payments (human-friendly). Reconciler resolves - to CAIP-2 format (e.g., "base-sepolia" → "eip155:84532"). + to CAIP-2 format (e.g., "base-sepolia" → "eip155:84532"). Required + when method=crypto (enforced by the payment XValidation rules); + unused for card payments. type: string payTo: - description: 'USDC recipient wallet address (x402: payTo).' + description: |- + USDC recipient wallet address (x402: payTo). Required and 0x-format + when method=crypto (enforced by the payment XValidation rules); + unused for card payments. pattern: ^0x[0-9a-fA-F]{40}$ type: string price: description: |- - Pricing table with per-unit prices in USDC (human-readable decimals). - Which fields are applicable depends on the workload type. + Pricing table with per-unit prices (human-readable decimals). For + crypto the unit is the settlement token (USDC by default); for card + the unit is payment.card.currency. Which fields are applicable + depends on the workload type. properties: perEpoch: description: Per-training-epoch price in USDC. Fine-tuning @@ -188,15 +295,27 @@ spec: type: object scheme: default: exact - description: x402 payment scheme. + description: x402 payment scheme. Only meaningful when method=crypto. enum: - exact type: string required: - - network - - payTo - price type: object + x-kubernetes-validations: + - message: payment.payTo is required when payment.method is crypto + rule: 'self.method != ''card'' ? has(self.payTo) : true' + - message: payment.network is required when payment.method is crypto + rule: 'self.method != ''card'' ? (has(self.network) && size(self.network) + > 0) : true' + - message: payment.card.account is required when payment.method is + card + rule: 'self.method == ''card'' ? (has(self.card) && has(self.card.account)) + : true' + - message: payment.mpp.tempo.payTo and payment.mpp.tempo.asset are + required when Tempo MPP is enabled + rule: 'has(self.mpp) && has(self.mpp.tempo) ? (has(self.mpp.tempo.payTo) + && has(self.mpp.tempo.asset)) : true' provenance: additionalProperties: type: string diff --git a/internal/embed/infrastructure/base/templates/x402.yaml b/internal/embed/infrastructure/base/templates/x402.yaml index 11f220ce..40cdb58e 100644 --- a/internal/embed/infrastructure/base/templates/x402.yaml +++ b/internal/embed/infrastructure/base/templates/x402.yaml @@ -57,6 +57,18 @@ 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: "" + # Random secret used to sign MPP WWW-Authenticate challenges. Required for + # Hermes/link-cli/mppx Authorization: Payment compatibility. + MPP_CHALLENGE_SECRET: "" + # Optional Tempo RPC endpoint used when a ServiceOffer enables + # spec.payment.mpp.tempo. + TEMPO_MPP_RPC_URL: "" --- apiVersion: v1 @@ -279,6 +291,28 @@ 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 + - name: MPP_CHALLENGE_SECRET + valueFrom: + secretKeyRef: + name: x402-secrets + key: MPP_CHALLENGE_SECRET + optional: true + - name: TEMPO_MPP_RPC_URL + valueFrom: + secretKeyRef: + name: x402-secrets + key: TEMPO_MPP_RPC_URL + optional: true volumeMounts: - name: pricing-config mountPath: /config diff --git a/internal/monetizeapi/types.go b/internal/monetizeapi/types.go index 2efb439b..6a75e856 100644 --- a/internal/monetizeapi/types.go +++ b/internal/monetizeapi/types.go @@ -192,31 +192,119 @@ type ServiceOfferUpstream struct { HealthPath string `json:"healthPath,omitempty"` } +// ServiceOfferPayment describes how buyers pay for the offer. Two methods +// are supported, selected by Method: +// +// - "crypto" (default): x402 on-chain stablecoin settlement. Network and +// PayTo are required and PayTo must be a 0x EVM address. +// - "card": an MPP credit-card method (Stripe stripe.charge). Card is +// required; funds settle off-chain into the configured Stripe account +// and Network/PayTo do not apply. +// +// The per-method required fields are enforced by the XValidation rules +// below so the API server rejects malformed offers at admission time, +// independent of the CLI. The CEL guards short-circuit on Method so the +// 0x/account checks are only evaluated for the relevant method. +// +// +kubebuilder:validation:XValidation:rule="self.method != 'card' ? has(self.payTo) : true",message="payment.payTo is required when payment.method is crypto" +// +kubebuilder:validation:XValidation:rule="self.method != 'card' ? (has(self.network) && size(self.network) > 0) : true",message="payment.network is required when payment.method is crypto" +// +kubebuilder:validation:XValidation:rule="self.method == 'card' ? (has(self.card) && has(self.card.account)) : true",message="payment.card.account is required when payment.method is card" +// +kubebuilder:validation:XValidation:rule="has(self.mpp) && has(self.mpp.tempo) ? (has(self.mpp.tempo.payTo) && has(self.mpp.tempo.asset)) : true",message="payment.mpp.tempo.payTo and payment.mpp.tempo.asset are required when Tempo MPP is enabled" type ServiceOfferPayment struct { - // x402 payment scheme. + // Payment method. "crypto" gates with x402 on-chain stablecoin + // settlement (default; preserves existing behavior). "card" gates with + // an MPP credit-card method (Stripe) that settles off-chain into + // spec.payment.card.account. + // +kubebuilder:default="crypto" + // +kubebuilder:validation:Enum=crypto;card + Method string `json:"method,omitempty"` + // x402 payment scheme. Only meaningful when method=crypto. // +kubebuilder:default="exact" // +kubebuilder:validation:Enum=exact Scheme string `json:"scheme,omitempty"` // Chain identifier for payments (human-friendly). Reconciler resolves - // to CAIP-2 format (e.g., "base-sepolia" → "eip155:84532"). - // +kubebuilder:validation:Required - Network string `json:"network"` - // USDC recipient wallet address (x402: payTo). - // +kubebuilder:validation:Required + // to CAIP-2 format (e.g., "base-sepolia" → "eip155:84532"). Required + // when method=crypto (enforced by the payment XValidation rules); + // unused for card payments. + Network string `json:"network,omitempty"` + // USDC recipient wallet address (x402: payTo). Required and 0x-format + // when method=crypto (enforced by the payment XValidation rules); + // unused for card payments. // +kubebuilder:validation:Pattern=`^0x[0-9a-fA-F]{40}$` - PayTo string `json:"payTo"` + PayTo string `json:"payTo,omitempty"` // Payment validity window in seconds (x402: maxTimeoutSeconds). // +kubebuilder:default=300 MaxTimeoutSeconds int64 `json:"maxTimeoutSeconds,omitempty"` // Optional token metadata override for x402 settlement. When omitted, - // the verifier uses the chain default asset. + // the verifier uses the chain default asset. Crypto only. Asset ServiceOfferAsset `json:"asset,omitempty"` - // Pricing table with per-unit prices in USDC (human-readable decimals). - // Which fields are applicable depends on the workload type. + // Card payment terms. Required when method=card; ignored otherwise. + Card *ServiceOfferCardPayment `json:"card,omitempty"` + // MPP advertises optional HTTP-auth payment rails alongside the primary + // method. Stripe SPT is configured in Card; Tempo is explicit here so + // operators can see when a route advertises direct Tempo payment. + MPP *ServiceOfferMPPPayment `json:"mpp,omitempty"` + // Pricing table with per-unit prices (human-readable decimals). For + // crypto the unit is the settlement token (USDC by default); for card + // the unit is payment.card.currency. Which fields are applicable + // depends on the workload type. // +kubebuilder:validation:Required Price ServiceOfferPriceTable `json:"price"` } +// ServiceOfferCardPayment holds the off-chain credit-card settlement terms +// used when ServiceOfferPayment.Method == "card". It is the card-method +// analog of Network/PayTo: instead of a chain plus a 0x recipient, funds +// settle through a payment provider (Stripe today, via the MPP +// stripe.charge method) into Account. +type ServiceOfferCardPayment struct { + // Card payment provider. Only "stripe" is supported today (MPP + // stripe.charge via Shared Payment Tokens). + // +kubebuilder:default="stripe" + // +kubebuilder:validation:Enum=stripe + Provider string `json:"provider,omitempty"` + // Destination account that receives settled card funds. For Stripe this + // is the connected/destination account id (e.g. "acct_1A2b3C4d5E6f7G"). + // +kubebuilder:validation:Pattern=`^acct_[A-Za-z0-9]+$` + Account string `json:"account,omitempty"` + // ISO-4217 currency the card is charged in. Default "usd". + // +kubebuilder:default="usd" + // +kubebuilder:validation:Pattern=`^[a-z]{3}$` + Currency string `json:"currency,omitempty"` + // Preferred Stripe profile id (profile_... or profile_test_...), surfaced + // in the MPP challenge so clients know where to mint a Shared Payment Token. + ProfileID string `json:"profileId,omitempty"` + // Accepted payment-method types advertised to the client. Defaults to + // ["card","link"] at the gateway when empty. + // +kubebuilder:validation:MaxItems=16 + PaymentMethodTypes []string `json:"paymentMethodTypes,omitempty"` +} + +// ServiceOfferMPPPayment groups optional MPP HTTP-auth rails. +type ServiceOfferMPPPayment struct { + Tempo *ServiceOfferTempoMPPPayment `json:"tempo,omitempty"` +} + +// ServiceOfferTempoMPPPayment advertises a Tempo MPP payment rail. V1 supports +// pull transaction credentials only so the verifier can broadcast after the +// protected upstream succeeds. +type ServiceOfferTempoMPPPayment struct { + // Recipient Tempo/EVM address for the primary transfer. + // +kubebuilder:validation:Pattern=`^0x[0-9a-fA-F]{40}$` + PayTo string `json:"payTo,omitempty"` + // Tempo token contract address. + // +kubebuilder:validation:Pattern=`^0x[0-9a-fA-F]{40}$` + Asset string `json:"asset,omitempty"` + // Token decimals. Defaults to 6 when omitted. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=255 + Decimals int64 `json:"decimals,omitempty"` + // Optional Tempo chain id for challenge binding. + ChainID int64 `json:"chainId,omitempty"` + // Optional human-readable network label, used for display only. + Network string `json:"network,omitempty"` +} + type ServiceOfferAsset struct { // ERC-20 contract address. // +kubebuilder:validation:Pattern=`^0x[0-9a-fA-F]{40}$` @@ -724,8 +812,7 @@ type AgentIdentityList struct { Items []AgentIdentity `json:"items"` } -type AgentIdentitySpec struct { -} +type AgentIdentitySpec struct{} type AgentIdentityStatus struct { // Per-chain ERC-8004 registrations for this identity document. diff --git a/internal/monetizeapi/zz_generated.deepcopy.go b/internal/monetizeapi/zz_generated.deepcopy.go index 3c0207f3..85ec48d9 100644 --- a/internal/monetizeapi/zz_generated.deepcopy.go +++ b/internal/monetizeapi/zz_generated.deepcopy.go @@ -570,6 +570,26 @@ func (in *ServiceOfferAsset) DeepCopy() *ServiceOfferAsset { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceOfferCardPayment) DeepCopyInto(out *ServiceOfferCardPayment) { + *out = *in + if in.PaymentMethodTypes != nil { + in, out := &in.PaymentMethodTypes, &out.PaymentMethodTypes + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceOfferCardPayment. +func (in *ServiceOfferCardPayment) DeepCopy() *ServiceOfferCardPayment { + if in == nil { + return nil + } + out := new(ServiceOfferCardPayment) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceOfferList) DeepCopyInto(out *ServiceOfferList) { *out = *in @@ -602,6 +622,26 @@ func (in *ServiceOfferList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceOfferMPPPayment) DeepCopyInto(out *ServiceOfferMPPPayment) { + *out = *in + if in.Tempo != nil { + in, out := &in.Tempo, &out.Tempo + *out = new(ServiceOfferTempoMPPPayment) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceOfferMPPPayment. +func (in *ServiceOfferMPPPayment) DeepCopy() *ServiceOfferMPPPayment { + if in == nil { + return nil + } + out := new(ServiceOfferMPPPayment) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceOfferModel) DeepCopyInto(out *ServiceOfferModel) { *out = *in @@ -621,6 +661,16 @@ func (in *ServiceOfferModel) DeepCopy() *ServiceOfferModel { func (in *ServiceOfferPayment) DeepCopyInto(out *ServiceOfferPayment) { *out = *in out.Asset = in.Asset + if in.Card != nil { + in, out := &in.Card, &out.Card + *out = new(ServiceOfferCardPayment) + (*in).DeepCopyInto(*out) + } + if in.MPP != nil { + in, out := &in.MPP, &out.MPP + *out = new(ServiceOfferMPPPayment) + (*in).DeepCopyInto(*out) + } out.Price = in.Price } @@ -712,7 +762,7 @@ func (in *ServiceOfferSpec) DeepCopyInto(out *ServiceOfferSpec) { out.Agent = in.Agent out.Model = in.Model out.Upstream = in.Upstream - out.Payment = in.Payment + in.Payment.DeepCopyInto(&out.Payment) if in.Provenance != nil { in, out := &in.Provenance, &out.Provenance *out = make(map[string]string, len(*in)) @@ -769,6 +819,21 @@ func (in *ServiceOfferStatus) DeepCopy() *ServiceOfferStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceOfferTempoMPPPayment) DeepCopyInto(out *ServiceOfferTempoMPPPayment) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceOfferTempoMPPPayment. +func (in *ServiceOfferTempoMPPPayment) DeepCopy() *ServiceOfferTempoMPPPayment { + if in == nil { + return nil + } + out := new(ServiceOfferTempoMPPPayment) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceOfferUpstream) DeepCopyInto(out *ServiceOfferUpstream) { *out = *in diff --git a/internal/schemas/payment.go b/internal/schemas/payment.go index 740d1eee..4384f662 100644 --- a/internal/schemas/payment.go +++ b/internal/schemas/payment.go @@ -29,10 +29,23 @@ var ( approxMinutesPerRequestDecimal = decimal.NewFromInt(ApproxMinutesPerRequest) ) -// PaymentTerms defines x402 payment requirements for a ServiceOffer. -// Field names align with x402 PaymentRequirements (V2). +// PaymentMethodCrypto gates the offer with x402 on-chain stablecoin +// settlement. It is the default when PaymentTerms.Method is empty. +const PaymentMethodCrypto = "crypto" + +// PaymentMethodCard gates the offer with an MPP credit-card method +// (Stripe stripe.charge), settled off-chain into PaymentTerms.Card.Account. +const PaymentMethodCard = "card" + +// PaymentTerms defines payment requirements for a ServiceOffer. Field +// names align with x402 PaymentRequirements (V2) for the crypto method; +// the Card block carries the off-chain credit-card (MPP/Stripe) terms. type PaymentTerms struct { - // Scheme is the x402 payment scheme. Default: "exact". + // Method selects the payment method: "crypto" (x402 on-chain, default) + // or "card" (MPP Stripe). Empty is treated as "crypto". + Method string `json:"method,omitempty" yaml:"method,omitempty"` + + // Scheme is the x402 payment scheme. Default: "exact". Crypto only. Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` // Network is the chain identifier (human-friendly, e.g., "base-sepolia"). @@ -47,12 +60,65 @@ type PaymentTerms struct { // Asset defines the token metadata used for x402 settlement. When omitted, // the verifier falls back to the chain default asset (currently USDC). + // Crypto only. Asset AssetTerms `json:"asset,omitempty" yaml:"asset,omitempty"` + // Card holds off-chain credit-card settlement terms when Method=="card". + Card *CardTerms `json:"card,omitempty" yaml:"card,omitempty"` + + // MPP holds optional HTTP-auth payment rails advertised alongside the + // primary method. + MPP *MPPTerms `json:"mpp,omitempty" yaml:"mpp,omitempty"` + // Price defines the pricing model (type-specific). Price PriceTable `json:"price" yaml:"price"` } +// EffectiveMethod returns the payment method, defaulting an empty value to +// PaymentMethodCrypto so existing crypto offers keep working unchanged. +func (p PaymentTerms) EffectiveMethod() string { + if p.Method == "" { + return PaymentMethodCrypto + } + return p.Method +} + +// CardTerms defines off-chain credit-card settlement terms used when +// PaymentTerms.Method == "card". It mirrors monetizeapi.ServiceOfferCardPayment. +type CardTerms struct { + // Provider is the card payment provider (only "stripe" today). + Provider string `json:"provider,omitempty" yaml:"provider,omitempty"` + + // Account is the destination account that receives settled card funds. + // For Stripe this is the connected/destination account id (acct_...). + Account string `json:"account,omitempty" yaml:"account,omitempty"` + + // Currency is the ISO-4217 currency the card is charged in (e.g. "usd"). + Currency string `json:"currency,omitempty" yaml:"currency,omitempty"` + + // ProfileID is the preferred Stripe profile id (profile_... or + // profile_test_...) surfaced in the MPP challenge. + ProfileID string `json:"profileId,omitempty" yaml:"profileId,omitempty"` + + // PaymentMethodTypes are the accepted payment-method types advertised to + // the client. Defaults to ["card","link"] at the gateway when empty. + PaymentMethodTypes []string `json:"paymentMethodTypes,omitempty" yaml:"paymentMethodTypes,omitempty"` +} + +// MPPTerms groups optional MPP HTTP-auth payment rails. +type MPPTerms struct { + Tempo *TempoMPPTerms `json:"tempo,omitempty" yaml:"tempo,omitempty"` +} + +// TempoMPPTerms advertises a Tempo MPP pull-payment rail. +type TempoMPPTerms struct { + PayTo string `json:"payTo,omitempty" yaml:"payTo,omitempty"` + Asset string `json:"asset,omitempty" yaml:"asset,omitempty"` + Decimals int `json:"decimals,omitempty" yaml:"decimals,omitempty"` + ChainID int64 `json:"chainId,omitempty" yaml:"chainId,omitempty"` + Network string `json:"network,omitempty" yaml:"network,omitempty"` +} + const ( AssetTransferMethodEIP3009 = "eip3009" AssetTransferMethodPermit2 = "permit2" diff --git a/internal/x402/card.go b/internal/x402/card.go new file mode 100644 index 00000000..95a8a70e --- /dev/null +++ b/internal/x402/card.go @@ -0,0 +1,535 @@ +package x402 + +// MPP credit-card (Stripe stripe.charge) settlement for the seller gateway. +// +// 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 (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. +// +// 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" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log" + "math/big" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + x402types "github.com/x402-foundation/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" + // defaultCardCurrency is the fallback ISO-4217 currency for card offers. + defaultCardCurrency = "usd" + + // 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 } + +// 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 { + return 2 + } + if c.Decimals > 0 { + return c.Decimals + } + return currencyMinorUnits(c.Currency) +} + +func (c *CardRoute) cardCurrency() string { + if c != nil && c.Currency != "" { + return strings.ToLower(c.Currency) + } + return defaultCardCurrency +} + +func (c *CardRoute) cardPaymentMethodTypes() []string { + if c != nil && len(c.PaymentMethodTypes) > 0 { + return c.PaymentMethodTypes + } + return []string{"card", "link"} +} + +func validateStripeCardMinimum(price, currency string, decimals int) error { + if strings.ToLower(strings.TrimSpace(currency)) != "usd" { + return nil + } + amountMinor, err := decimalToAtomicChecked(price, decimals) + if err != nil { + return fmt.Errorf("invalid Stripe card amount %q", price) + } + n, ok := new(big.Int).SetString(amountMinor, 10) + if !ok { + return fmt.Errorf("invalid Stripe card amount %q", price) + } + if n.Cmp(big.NewInt(50)) < 0 { + return fmt.Errorf("Stripe SPT card payments require at least 0.50 usd; got %s usd", price) + } + return nil +} + +func decimalToAtomicChecked(amount string, decimals int) (string, error) { + if decimals < 0 { + return "", fmt.Errorf("invalid decimals %d", decimals) + } + amountFloat, _, err := new(big.Float).SetPrec(128).Parse(strings.TrimSpace(amount), 10) + if err != nil || amountFloat == nil { + return "", err + } + multiplier := new(big.Float).SetPrec(128).SetInt( + new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil), + ) + atomicFloat := new(big.Float).SetPrec(128).Mul(amountFloat, multiplier) + atomicFloat.Add(atomicFloat, new(big.Float).SetPrec(128).SetFloat64(0.5)) + atomicInt, _ := atomicFloat.Int(nil) + return atomicInt.String(), nil +} + +// 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() + currency := card.cardCurrency() + pmt := card.cardPaymentMethodTypes() + amountMinor := decimalToAtomic(rule.Price, decimals) + profileID := stripeProfileID(card) + + 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, + "profileId": profileID, + "networkId": profileID, + "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{ + "profileId": profileID, + "networkId": profileID, + "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:{...}}). +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") +} + +// ── 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() +} + +// 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 + baseURL string + // secretKey returns the seller's Stripe secret key. + secretKey func() string +} + +func newStripeCardGateway() *stripeCardGateway { + return &stripeCardGateway{ + httpClient: &http.Client{Timeout: cardStripeTimeout}, + baseURL: stripeAPIBase, + secretKey: func() string { return strings.TrimSpace(os.Getenv("STRIPE_SECRET_KEY")) }, + } +} + +// 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) +) + +// 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 +} + +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)") + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return "", "", fmt.Errorf("build stripe request: %w", err) + } + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(key+":"))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Stripe-Version", "2026-03-04.preview") + 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) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + 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 body.ID, body.Status, nil +} + +// 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, + "intent": "charge", + "reference": reference, + }) + if err != nil { + return []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. 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, + rule *RouteRule, + requirement x402types.PaymentRequirements, + extensions map[string]any, + proxy http.Handler, + gw cardGateway, + guard *sptReplayGuard, +) { + reqs := []x402types.PaymentRequirements{requirement} + + paymentHeader := r.Header.Get("X-PAYMENT") + authHeader := mppPaymentAuthorization(r) + if paymentHeader == "" && authHeader == "" { + sendMPPPaymentRequiredJSON(w, r, rule, reqs, extensions) + return + } + + var ( + cred cardCredential + err error + ) + if authHeader != "" { + cred, err = parseStripeMPPCredential(authHeader, mppRealm(r), rule) + } else { + cred, err = parseCardCredential(paymentHeader) + } + if err != nil { + log.Printf("x402-card: bad credential for %s/%s: %v", rule.OfferNamespace, rule.OfferName, err) + sendMPPPaymentRequiredJSON(w, r, rule, reqs, extensions) + 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) + sendMPPPaymentRequiredJSON(w, r, rule, reqs, extensions) + return + } + + currency, _ := requirement.Extra["currency"].(string) + + authCtx, cancelAuth := detachedCardContext() + paymentIntentID, err := gw.authorize(authCtx, rule.Card, requirement.Amount, currency, cred) + cancelAuth() + if err != nil { + // 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) + sendMPPPaymentRequiredJSON(w, r, rule, reqs, extensions) + return + } + + // 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) + setCardReceiptHeaders(w, 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 new file mode 100644 index 00000000..b6b55b6d --- /dev/null +++ b/internal/x402/card_test.go @@ -0,0 +1,503 @@ +package x402 + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/tempoxyz/mpp-go/pkg/mpp" +) + +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", + Decimals: 2, + ProfileID: "profile_test_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 || 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) + } + 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", req.Asset) + } + if req.Extra["currency"] != "usd" || req.Extra["profileId"] != "profile_test_abc" || req.Extra["networkId"] != "profile_test_abc" { + t.Errorf("extra = %v", req.Extra) + } + pmt, ok := req.Extra["paymentMethodTypes"].([]string) + if !ok || len(pmt) != 2 || pmt[0] != "card" || pmt[1] != "link" { + t.Errorf("extra.paymentMethodTypes = %v, want [card link]", req.Extra["paymentMethodTypes"]) + } +} + +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"]) + } +} + +func TestValidateStripeCardMinimum(t *testing.T) { + tests := []struct { + name string + price string + currency string + decimals int + wantErr bool + }{ + {name: "usd below minimum", price: "0.49", currency: "usd", decimals: 2, wantErr: true}, + {name: "usd at minimum", price: "0.50", currency: "usd", decimals: 2}, + {name: "usd above minimum", price: "1.00", currency: "USD", decimals: 2}, + {name: "non usd bypasses stripe minimum", price: "0.01", currency: "eur", decimals: 2}, + {name: "invalid usd amount", price: "not-a-number", currency: "usd", decimals: 2, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateStripeCardMinimum(tt.price, tt.currency, tt.decimals) + if (err != nil) != tt.wantErr { + t.Fatalf("validateStripeCardMinimum(%q, %q, %d) err=%v, wantErr=%v", tt.price, tt.currency, tt.decimals, err, tt.wantErr) + } + }) + } +} + +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 || cred.SPT != "spt_abc" || cred.ExternalID != "e1" { + t.Fatalf("got %+v err=%v", cred, err) + } + }) + 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", "!!!"}, + {"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 TestBuildAuthorizeForm(t *testing.T) { + form := buildAuthorizeForm("1", "usd", "spt_abc") + want := map[string]string{ + "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 { + t.Errorf("form[%q] = %q, want %q", k, form.Get(k), v) + } + } +} + +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") + } +} + +// ── stripeCardGateway against a mock Stripe server ────────────────────────── + +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) + } + if r.Header.Get("Stripe-Version") != "2026-03-04.preview" { + t.Errorf("Stripe-Version = %q", r.Header.Get("Stripe-Version")) + } + _ = 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() + + 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 err := gw.capture(ctx, nil, id); err != nil { + t.Fatalf("capture: %v", err) + } + if err := gw.cancel(ctx, nil, id); err != nil { + t.Fatalf("cancel: %v", err) + } + if len(paths) != 3 { + t.Errorf("expected 3 Stripe calls, got %v", paths) + } +} + +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 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) + }) +} + +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) + if sptHeader != "" { + r.Header.Set("X-PAYMENT", sptHeader) + } + w := httptest.NewRecorder() + (&Verifier{}).serveCardGated(w, r, rule, req, nil, proxy, gw, guard) + return w +} + +func gateOnceWithAuth(gw cardGateway, guard *sptReplayGuard, auth string, proxy http.Handler) *httptest.ResponseRecorder { + rule := cardTestRule() + req := buildCardRequirement(rule) + r := httptest.NewRequest(http.MethodPost, "/services/card-foo/x", nil) + r.Host = "seller.example" + if auth != "" { + r.Header.Set("Authorization", auth) + } + w := httptest.NewRecorder() + (&Verifier{}).serveCardGated(w, r, rule, req, nil, proxy, gw, guard) + return w +} + +func TestServeCardGated_NoPayment402(t *testing.T) { + t.Setenv(mppChallengeSecretEnv, "test-secret") + 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 got := w.Header().Values("WWW-Authenticate"); len(got) != 1 || !strings.Contains(got[0], `method="stripe"`) { + t.Fatalf("WWW-Authenticate = %v, want stripe Payment challenge", got) + } + if gw.authCalls != 0 { + t.Error("authorize must not be called without a credential") + } +} + +func TestServeCardGated_AuthorizationPaymentStripe(t *testing.T) { + t.Setenv(mppChallengeSecretEnv, "test-secret") + rule := cardTestRule() + challenge, err := stripeMPPChallenge(rule, "seller.example", mppChallengeSecret()) + if err != nil { + t.Fatalf("stripeMPPChallenge: %v", err) + } + auth := challenge.NewCredential(map[string]any{"shared_payment_token": "spt_auth"}).ToAuthorization() + gw := &fakeGateway{pi: "pi_auth"} + w := gateOnceWithAuth(gw, newSPTReplayGuard(time.Hour), auth, okProxy()) + + if w.Code != http.StatusOK || w.Body.String() != "upstream-ok" { + t.Fatalf("status=%d body=%q", w.Code, w.Body.String()) + } + if gw.authCalls != 1 || len(gw.captured) != 1 { + t.Fatalf("auth=%d captured=%v", gw.authCalls, gw.captured) + } + if w.Header().Get("Payment-Receipt") == "" || w.Header().Get("Authentication-Info") == "" { + t.Fatalf("missing MPP receipt headers: %v", w.Header()) + } + if _, err := mpp.ParseReceipt(w.Header().Get("Payment-Receipt")); err != nil { + t.Fatalf("Payment-Receipt is not parseable: %v", err) + } +} + +func TestVerifierHandleProxyCardRouteUsesCardGateway(t *testing.T) { + fac := newMockFacilitator(t, mockFacilitatorOpts{}) + upstreamCalls := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls++ + if r.URL.Path != "/v1/chat/completions" { + t.Errorf("upstream path = %q, want /v1/chat/completions", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "card-upstream-ok") + })) + defer upstream.Close() + + route := *cardTestRule() + route.Price = "0.50" + route.UpstreamURL = upstream.URL + route.StripPrefix = "/services/card-foo" + gw := &fakeGateway{pi: "pi_proxy"} + oldGateway, oldGuard := defaultCardGateway, defaultSPTGuard + defaultCardGateway = gw + defaultSPTGuard = newSPTReplayGuard(time.Hour) + t.Cleanup(func() { + defaultCardGateway = oldGateway + defaultSPTGuard = oldGuard + }) + v := newTestVerifier(t, fac.URL, []RouteRule{route}) + + r := httptest.NewRequest(http.MethodPost, "/services/card-foo/v1/chat/completions", nil) + r.Header.Set("X-PAYMENT", cardCredHeader("spt_proxy")) + w := httptest.NewRecorder() + v.HandleProxy(w, r) + + if w.Code != http.StatusOK || w.Body.String() != "card-upstream-ok" { + t.Fatalf("status=%d body=%q", w.Code, w.Body.String()) + } + if upstreamCalls != 1 || gw.authCalls != 1 || len(gw.captured) != 1 || gw.captured[0] != "pi_proxy" { + t.Fatalf("upstream=%d auth=%d captured=%v", upstreamCalls, gw.authCalls, gw.captured) + } + if fac.verifyCalls.Load() != 0 || fac.settleCalls.Load() != 0 { + t.Fatalf("card route must not use x402 facilitator: verify=%d settle=%d", fac.verifyCalls.Load(), fac.settleCalls.Load()) + } +} + +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", w.Code, w.Body.String()) + } + if gw.authCalls != 1 || len(gw.captured) != 1 || gw.captured[0] != "pi_1" { + t.Fatalf("auth=%d captured=%v", gw.authCalls, gw.captured) + } + 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_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_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") + } +} + +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") + } +} + +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") }) + + // 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 !guard.tryReserve("spt_a") { + t.Error("SPT should be released after capture failure") + } +} diff --git a/internal/x402/config.go b/internal/x402/config.go index a878ac48..8accbec5 100644 --- a/internal/x402/config.go +++ b/internal/x402/config.go @@ -136,6 +136,49 @@ type RouteRule struct { // minutes-to-hours here — operator-set values up to // MaxMaxTimeoutSeconds are honored verbatim. MaxTimeoutSeconds int64 `yaml:"maxTimeoutSeconds,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"` + + // MPPTempo, when non-nil, advertises a Tempo MPP pull-payment option for + // this route. It can ride alongside Card so Hermes/mppx clients can choose + // between Stripe SPT and Tempo. + MPPTempo *TempoMPPRoute `yaml:"mppTempo,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"` + // ProfileID is the Stripe profile id advertised in the MPP challenge so + // clients can mint an SPT. + ProfileID string `yaml:"profileId,omitempty"` + // PaymentMethodTypes are the accepted Stripe payment-method types, + // advertised in the challenge (defaults to ["card","link"]). + PaymentMethodTypes []string `yaml:"paymentMethodTypes,omitempty"` +} + +// TempoMPPRoute carries the per-route Tempo MPP terms. V1 supports pull +// transaction credentials only so the verifier can submit after upstream +// success and preserve Obol's charge-after-success invariant. +type TempoMPPRoute struct { + PayTo string `yaml:"payTo,omitempty"` + Asset string `yaml:"asset,omitempty"` + Decimals int `yaml:"decimals,omitempty"` + ChainID int64 `yaml:"chainId,omitempty"` + Network string `yaml:"network,omitempty"` } // LoadConfig reads and parses a pricing configuration YAML file. diff --git a/internal/x402/mpp.go b/internal/x402/mpp.go new file mode 100644 index 00000000..fef3a0d7 --- /dev/null +++ b/internal/x402/mpp.go @@ -0,0 +1,475 @@ +package x402 + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/tempoxyz/mpp-go/pkg/mpp" + mppserver "github.com/tempoxyz/mpp-go/pkg/server" + "github.com/tempoxyz/mpp-go/pkg/tempo" + temposerver "github.com/tempoxyz/mpp-go/pkg/tempo/server" + tempotx "github.com/tempoxyz/tempo-go/pkg/transaction" + x402types "github.com/x402-foundation/x402/go/types" +) + +const ( + mppMethodStripe = "stripe" + mppMethodTempo = "tempo" + mppIntentCharge = "charge" + + mppChallengeSecretEnv = "MPP_CHALLENGE_SECRET" + tempoMPPRPCURLEnv = "TEMPO_MPP_RPC_URL" + + tempoMPPTimeout = 60 * time.Second +) + +func firstNonEmpty(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + return "" +} + +func mppChallengeSecret() string { + return strings.TrimSpace(os.Getenv(mppChallengeSecretEnv)) +} + +func mppRealm(r *http.Request) string { + if r.Host != "" { + return r.Host + } + if host := r.Header.Get("X-Forwarded-Host"); host != "" { + return host + } + return ResourceServiceName +} + +func mppExpires(rule *RouteRule) string { + seconds := rule.MaxTimeoutSeconds + if seconds <= 0 { + seconds = DefaultMaxTimeoutSeconds + } + return time.Now().UTC().Add(time.Duration(seconds) * time.Second).Format(time.RFC3339) +} + +func stripeProfileID(card *CardRoute) string { + if card == nil { + return "" + } + return firstNonEmpty(card.ProfileID, os.Getenv("STRIPE_PROFILE_ID")) +} + +func validStripeProfileID(profileID string) bool { + return strings.HasPrefix(profileID, "profile_") || strings.HasPrefix(profileID, "profile_test_") +} + +func stripeMPPRequest(rule *RouteRule) map[string]any { + card := rule.Card + decimals := card.cardDecimals() + currency := card.cardCurrency() + profileID := stripeProfileID(card) + return map[string]any{ + "amount": rule.Price, + "currency": currency, + "decimals": decimals, + "methodDetails": map[string]any{ + "profileId": profileID, + "networkId": profileID, + "paymentMethodTypes": card.cardPaymentMethodTypes(), + }, + } +} + +func stripeMPPChallenge(rule *RouteRule, realm, secret string) (*mpp.Challenge, error) { + if secret == "" { + return nil, errors.New("missing MPP challenge secret") + } + profileID := stripeProfileID(rule.Card) + if !validStripeProfileID(profileID) { + return nil, fmt.Errorf("invalid Stripe profile id %q", profileID) + } + return mpp.NewChallenge( + secret, + realm, + mppMethodStripe, + mppIntentCharge, + stripeMPPRequest(rule), + mpp.WithExpires(mppExpires(rule)), + ), nil +} + +func tempoMPPRequest(rule *RouteRule) map[string]any { + tempoRoute := rule.MPPTempo + decimals := tempoRoute.Decimals + if decimals == 0 { + decimals = tempo.DefaultDecimals + } + params := tempo.ChargeRequestParams{ + Amount: rule.Price, + Currency: tempoRoute.Asset, + Recipient: tempoRoute.PayTo, + Decimals: decimals, + Description: rule.Description, + ExternalID: rule.OfferNamespace + "/" + rule.OfferName, + ChainID: tempoRoute.ChainID, + SupportedModes: []tempo.ChargeMode{tempo.ChargeModePull}, + } + req, err := tempo.NormalizeChargeRequest(params) + if err != nil { + return map[string]any{} + } + return req.Map() +} + +func tempoMPPChallenge(rule *RouteRule, realm, secret string) (*mpp.Challenge, error) { + if rule.MPPTempo == nil { + return nil, errors.New("Tempo MPP not configured") + } + if secret == "" { + return nil, errors.New("missing MPP challenge secret") + } + request := tempoMPPRequest(rule) + if len(request) == 0 { + return nil, errors.New("invalid Tempo MPP request") + } + return mpp.NewChallenge( + secret, + realm, + mppMethodTempo, + mppIntentCharge, + request, + mpp.WithExpires(mppExpires(rule)), + ), nil +} + +func addMPPAuthenticateHeaders(w http.ResponseWriter, r *http.Request, rule *RouteRule) { + secret := mppChallengeSecret() + if secret == "" { + if rule.IsCard() || rule.MPPTempo != nil { + log.Printf("x402-mpp: %s unset; serving legacy x402/card 402 without WWW-Authenticate", mppChallengeSecretEnv) + } + return + } + realm := mppRealm(r) + if rule.IsCard() { + challenge, err := stripeMPPChallenge(rule, realm, secret) + if err != nil { + log.Printf("x402-mpp: skip stripe challenge for %s/%s: %v", rule.OfferNamespace, rule.OfferName, err) + } else if header, err := challenge.ToAuthenticateStrict(realm); err == nil { + w.Header().Add("WWW-Authenticate", header) + } + } + if rule.MPPTempo != nil { + challenge, err := tempoMPPChallenge(rule, realm, secret) + if err != nil { + log.Printf("x402-mpp: skip tempo challenge for %s/%s: %v", rule.OfferNamespace, rule.OfferName, err) + } else if header, err := challenge.ToAuthenticateStrict(realm); err == nil { + w.Header().Add("WWW-Authenticate", header) + } + } +} + +func sendMPPPaymentRequiredJSON(w http.ResponseWriter, r *http.Request, rule *RouteRule, requirements []x402types.PaymentRequirements, extensions map[string]any) { + addMPPAuthenticateHeaders(w, r, rule) + sendPaymentRequiredJSON(w, r, requirements, extensions) +} + +func mppPaymentAuthorization(r *http.Request) string { + return mpp.FindPaymentAuthorization(r.Header.Get("Authorization")) +} + +func mppAuthorizationMethod(r *http.Request) string { + auth := mppPaymentAuthorization(r) + if auth == "" { + return "" + } + cred, err := mpp.ParseCredential(auth) + if err != nil { + return "" + } + return cred.Challenge.Method +} + +func validateMPPCredential(auth, realm, secret, method, intent string, request map[string]any) (*mpp.Credential, error) { + if secret == "" { + return nil, fmt.Errorf("%s is required for MPP Authorization credentials", mppChallengeSecretEnv) + } + cred, err := mpp.ParseCredential(auth) + if err != nil { + return nil, err + } + echoedRequest, err := mpp.B64Decode(cred.Challenge.Request) + if err != nil { + return nil, fmt.Errorf("invalid echoed MPP request: %w", err) + } + expected := mpp.NewChallenge( + secret, + cred.Challenge.Realm, + cred.Challenge.Method, + cred.Challenge.Intent, + echoedRequest, + echoedChallengeOptions(cred)..., + ) + if !mpp.ConstantTimeEqual(cred.Challenge.ID, expected.ID) { + return nil, errors.New("MPP challenge was not issued by this verifier") + } + if cred.Challenge.Realm != realm { + return nil, errors.New("MPP realm mismatch") + } + if cred.Challenge.Method != method || cred.Challenge.Intent != intent { + return nil, fmt.Errorf("unsupported MPP credential %s/%s", cred.Challenge.Method, cred.Challenge.Intent) + } + if !mpp.JSONEqual(echoedRequest, request) { + return nil, errors.New("MPP credential request does not match route") + } + if cred.Challenge.Expires == "" { + return nil, errors.New("MPP credential missing expiry") + } + expires, err := time.Parse(time.RFC3339, cred.Challenge.Expires) + if err != nil { + return nil, fmt.Errorf("invalid MPP expiry: %w", err) + } + if time.Now().UTC().After(expires) { + return nil, errors.New("MPP credential expired") + } + return cred, nil +} + +func echoedChallengeOptions(cred *mpp.Credential) []mpp.ChallengeOption { + var opts []mpp.ChallengeOption + if cred.Challenge.Expires != "" { + opts = append(opts, mpp.WithExpires(cred.Challenge.Expires)) + } + if cred.Challenge.Digest != "" { + opts = append(opts, mpp.WithDigest(cred.Challenge.Digest)) + } + if cred.Challenge.Opaque != nil { + opts = append(opts, mpp.WithMeta(cred.Challenge.Opaque)) + } + return opts +} + +func parseStripeMPPCredential(auth, realm string, rule *RouteRule) (cardCredential, error) { + cred, err := validateMPPCredential(auth, realm, mppChallengeSecret(), mppMethodStripe, mppIntentCharge, stripeMPPRequest(rule)) + if err != nil { + return cardCredential{}, err + } + spt := firstNonEmpty( + anyString(cred.Payload["spt"]), + anyString(cred.Payload["shared_payment_token"]), + anyString(cred.Payload["sharedPaymentToken"]), + ) + return cardCredential{SPT: spt, ExternalID: anyString(cred.Payload["externalId"])}.normalize() +} + +func anyString(v any) string { + switch t := v.(type) { + case string: + return strings.TrimSpace(t) + default: + return "" + } +} + +func setMPPReceiptHeaders(w http.ResponseWriter, receipt *mpp.Receipt) { + if receipt == nil { + return + } + header := receipt.ToPaymentReceipt() + w.Header().Set("Payment-Receipt", header) + w.Header().Set("Authentication-Info", header) +} + +func setCardReceiptHeaders(w http.ResponseWriter, reference string) { + receiptJSON := cardReceiptJSON(reference) + w.Header().Set("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(receiptJSON)) + receipt := mpp.Success(reference, mpp.WithReceiptMethod(mppMethodStripe)) + setMPPReceiptHeaders(w, receipt) +} + +// tempoMPPGateway settles a preflighted Tempo pull credential after the +// upstream succeeds. +type tempoMPPGateway interface { + preflight(r *http.Request, rule *RouteRule) (*tempoMPPAuthorization, error) + settle(ctx context.Context, auth *tempoMPPAuthorization, rule *RouteRule) (*mpp.Receipt, error) + release(auth *tempoMPPAuthorization) +} + +type tempoMPPAuthorization struct { + Authorization string + ChallengeID string + Realm string +} + +type tempoMPPGatewayImpl struct { + mu sync.Mutex + reserved map[string]time.Time +} + +func newTempoMPPGateway() *tempoMPPGatewayImpl { + return &tempoMPPGatewayImpl{reserved: make(map[string]time.Time)} +} + +var defaultTempoMPPGateway tempoMPPGateway = newTempoMPPGateway() + +func (g *tempoMPPGatewayImpl) preflight(r *http.Request, rule *RouteRule) (*tempoMPPAuthorization, error) { + auth := mppPaymentAuthorization(r) + if auth == "" { + return nil, errors.New("missing MPP Authorization") + } + cred, err := validateMPPCredential(auth, mppRealm(r), mppChallengeSecret(), mppMethodTempo, mppIntentCharge, tempoMPPRequest(rule)) + if err != nil { + return nil, err + } + payloadType := anyString(cred.Payload["type"]) + if payloadType != string(tempo.CredentialTypeTransaction) { + return nil, fmt.Errorf("Tempo MPP only supports pull transaction credentials; got %q", payloadType) + } + if err := preflightTempoTransactionSignature(cred); err != nil { + return nil, err + } + g.mu.Lock() + defer g.mu.Unlock() + now := time.Now() + for key, seen := range g.reserved { + if now.Sub(seen) > time.Hour { + delete(g.reserved, key) + } + } + if _, exists := g.reserved[cred.Challenge.ID]; exists { + return nil, errors.New("Tempo MPP challenge already in flight") + } + g.reserved[cred.Challenge.ID] = now + return &tempoMPPAuthorization{Authorization: auth, ChallengeID: cred.Challenge.ID, Realm: mppRealm(r)}, nil +} + +func preflightTempoTransactionSignature(cred *mpp.Credential) error { + raw := anyString(cred.Payload["signature"]) + if raw == "" { + return errors.New("Tempo MPP transaction credential missing signature") + } + tx, err := tempotx.Deserialize(raw) + if err != nil { + return fmt.Errorf("invalid Tempo transaction payload: %w", err) + } + if _, err := tempotx.VerifySignature(tx); err != nil { + return fmt.Errorf("invalid Tempo transaction signature: %w", err) + } + return nil +} + +func (g *tempoMPPGatewayImpl) release(auth *tempoMPPAuthorization) { + if auth == nil { + return + } + g.mu.Lock() + delete(g.reserved, auth.ChallengeID) + g.mu.Unlock() +} + +func (g *tempoMPPGatewayImpl) settle(ctx context.Context, auth *tempoMPPAuthorization, rule *RouteRule) (*mpp.Receipt, error) { + if auth == nil { + return nil, errors.New("missing Tempo MPP authorization") + } + method, err := temposerver.MethodFromConfig(temposerver.Config{ + Currency: rule.MPPTempo.Asset, + Recipient: rule.MPPTempo.PayTo, + Decimals: rule.MPPTempo.Decimals, + ChainID: rule.MPPTempo.ChainID, + RPCURL: os.Getenv(tempoMPPRPCURLEnv), + SupportedModes: []tempo.ChargeMode{tempo.ChargeModePull}, + }) + if err != nil { + return nil, err + } + server := mppserver.New(method, auth.Realm, mppChallengeSecret()) + ctx, cancel := context.WithTimeout(ctx, tempoMPPTimeout) + defer cancel() + result, err := server.Charge(ctx, mppserver.ChargeParams{ + Authorization: auth.Authorization, + Amount: rule.Price, + Currency: rule.MPPTempo.Asset, + Recipient: rule.MPPTempo.PayTo, + ExternalID: rule.OfferNamespace + "/" + rule.OfferName, + Description: rule.Description, + SupportedModes: []tempo.ChargeMode{tempo.ChargeModePull}, + ChainID: int(rule.MPPTempo.ChainID), + }) + if err != nil { + return nil, err + } + if result.Receipt == nil { + return nil, errors.New("Tempo MPP settlement did not return a receipt") + } + return result.Receipt, nil +} + +func mppReceiptJSON(receipt *mpp.Receipt) []byte { + b, err := json.Marshal(receipt) + if err != nil { + return []byte("{}") + } + return b +} + +func (v *Verifier) serveTempoMPPGated( + w http.ResponseWriter, + r *http.Request, + rule *RouteRule, + requirement x402types.PaymentRequirements, + extensions map[string]any, + proxy http.Handler, + gw tempoMPPGateway, +) { + reqs := []x402types.PaymentRequirements{requirement} + auth, err := gw.preflight(r, rule) + if err != nil { + log.Printf("x402-mpp: bad Tempo credential for %s/%s: %v", rule.OfferNamespace, rule.OfferName, err) + sendMPPPaymentRequiredJSON(w, r, rule, reqs, extensions) + return + } + + interceptor := &settlementInterceptor{ + w: w, + settleFunc: func() bool { + receipt, err := gw.settle(r.Context(), auth, rule) + if err != nil { + gw.release(auth) + log.Printf("x402-mpp: Tempo settlement failed for %s/%s: %v", rule.OfferNamespace, rule.OfferName, err) + http.Error(w, "Tempo MPP settlement failed", http.StatusBadGateway) + return false + } + setMPPReceiptHeaders(w, receipt) + w.Header().Set("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(mppReceiptJSON(receipt))) + return true + }, + onFailure: func(statusCode int) { + gw.release(auth) + log.Printf("x402-mpp: upstream returned %d for %s/%s, Tempo transaction not broadcast", statusCode, rule.OfferNamespace, rule.OfferName) + }, + } + + defer func() { + rec := recover() + if !interceptor.committed { + gw.release(auth) + log.Printf("x402-mpp: upstream produced no response for %s/%s, Tempo transaction not broadcast", rule.OfferNamespace, rule.OfferName) + } + if rec != nil { + panic(rec) + } + }() + proxy.ServeHTTP(interceptor, r) +} diff --git a/internal/x402/mpp_test.go b/internal/x402/mpp_test.go new file mode 100644 index 00000000..01aff3d0 --- /dev/null +++ b/internal/x402/mpp_test.go @@ -0,0 +1,299 @@ +package x402 + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/tempoxyz/mpp-go/pkg/mpp" + "github.com/tempoxyz/mpp-go/pkg/tempo" +) + +func tempoTestRule() *RouteRule { + return &RouteRule{ + Pattern: "/services/tempo-foo/*", + Price: "0.50", + Description: "tempo test route", + OfferNamespace: "default", + OfferName: "tempo-foo", + MPPTempo: &TempoMPPRoute{ + PayTo: "0x1111111111111111111111111111111111111111", + Asset: "0x2222222222222222222222222222222222222222", + Decimals: 6, + ChainID: 12345, + }, + } +} + +type fakeTempoGateway struct { + preflightErr error + settleErr error + preflightN int + settleN int + releaseN int +} + +func (f *fakeTempoGateway) preflight(_ *http.Request, _ *RouteRule) (*tempoMPPAuthorization, error) { + f.preflightN++ + if f.preflightErr != nil { + return nil, f.preflightErr + } + return &tempoMPPAuthorization{Authorization: "Payment test", ChallengeID: "challenge-1", Realm: "seller.example"}, nil +} + +func (f *fakeTempoGateway) settle(context.Context, *tempoMPPAuthorization, *RouteRule) (*mpp.Receipt, error) { + f.settleN++ + if f.settleErr != nil { + return nil, f.settleErr + } + return mpp.Success("0xtx", mpp.WithReceiptMethod(mppMethodTempo)), nil +} + +func (f *fakeTempoGateway) release(*tempoMPPAuthorization) { f.releaseN++ } + +func gateTempoOnce(gw tempoMPPGateway, proxy http.Handler) *httptest.ResponseRecorder { + rule := tempoTestRule() + req := buildCardRequirement(cardTestRule()) + r := httptest.NewRequest(http.MethodPost, "/services/tempo-foo/x", nil) + r.Host = "seller.example" + r.Header.Set("Authorization", "Payment fake") + w := httptest.NewRecorder() + (&Verifier{}).serveTempoMPPGated(w, r, rule, req, nil, proxy, gw) + return w +} + +func TestServeTempoMPPGated_SettlesAfterUpstreamSuccess(t *testing.T) { + gw := &fakeTempoGateway{} + w := gateTempoOnce(gw, okProxy()) + if w.Code != http.StatusOK || w.Body.String() != "upstream-ok" { + t.Fatalf("status=%d body=%q", w.Code, w.Body.String()) + } + if gw.preflightN != 1 || gw.settleN != 1 || gw.releaseN != 0 { + t.Fatalf("preflight=%d settle=%d release=%d", gw.preflightN, gw.settleN, gw.releaseN) + } + if w.Header().Get("Payment-Receipt") == "" || w.Header().Get("Authentication-Info") == "" { + t.Fatalf("missing MPP receipt headers: %v", w.Header()) + } +} + +func TestServeTempoMPPGated_UpstreamFailureDoesNotSettle(t *testing.T) { + gw := &fakeTempoGateway{} + w := gateTempoOnce(gw, failProxy()) + if w.Code != http.StatusInternalServerError { + t.Fatalf("status=%d, want 500", w.Code) + } + if gw.settleN != 0 || gw.releaseN != 1 { + t.Fatalf("settle=%d release=%d", gw.settleN, gw.releaseN) + } +} + +func TestServeTempoMPPGated_PreflightFailureDoesNotReachUpstream(t *testing.T) { + gw := &fakeTempoGateway{preflightErr: errors.New("bad credential")} + called := false + proxy := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + _, _ = io.WriteString(w, "should-not-run") + }) + w := gateTempoOnce(gw, proxy) + if w.Code != http.StatusPaymentRequired { + t.Fatalf("status=%d, want 402", w.Code) + } + if called { + t.Fatal("upstream must not run after preflight failure") + } +} + +func TestServeTempoMPPGated_SettleFailureReleasesReservation(t *testing.T) { + gw := &fakeTempoGateway{settleErr: errors.New("settle failed")} + w := gateTempoOnce(gw, okProxy()) + if w.Code != http.StatusBadGateway { + t.Fatalf("status=%d, want 502", w.Code) + } + if gw.preflightN != 1 || gw.settleN != 1 || gw.releaseN != 1 { + t.Fatalf("preflight=%d settle=%d release=%d", gw.preflightN, gw.settleN, gw.releaseN) + } + if w.Header().Get("Payment-Receipt") != "" || w.Header().Get("X-PAYMENT-RESPONSE") != "" { + t.Fatalf("settlement failure must not emit receipt headers: %v", w.Header()) + } +} + +func TestVerifierHandleProxyTempoAuthorizationUsesMPPGateway(t *testing.T) { + t.Setenv(mppChallengeSecretEnv, "test-secret") + fac := newMockFacilitator(t, mockFacilitatorOpts{}) + upstreamCalls := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls++ + if r.URL.Path != "/x" { + t.Errorf("upstream path = %q, want /x", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "tempo-upstream-ok") + })) + defer upstream.Close() + + route := *tempoTestRule() + route.UpstreamURL = upstream.URL + route.StripPrefix = "/services/tempo-foo" + challenge, err := tempoMPPChallenge(&route, "seller.example", mppChallengeSecret()) + if err != nil { + t.Fatalf("tempoMPPChallenge: %v", err) + } + gw := &fakeTempoGateway{} + oldGateway := defaultTempoMPPGateway + defaultTempoMPPGateway = gw + t.Cleanup(func() { defaultTempoMPPGateway = oldGateway }) + v := newTestVerifier(t, fac.URL, []RouteRule{route}) + + r := httptest.NewRequest(http.MethodPost, "/services/tempo-foo/x", nil) + r.Host = "seller.example" + r.Header.Set("Authorization", challenge.NewCredential(map[string]any{ + "type": string(tempo.CredentialTypeTransaction), + "signature": "0x76", + }).ToAuthorization()) + w := httptest.NewRecorder() + v.HandleProxy(w, r) + + if w.Code != http.StatusOK || w.Body.String() != "tempo-upstream-ok" { + t.Fatalf("status=%d body=%q", w.Code, w.Body.String()) + } + if upstreamCalls != 1 || gw.preflightN != 1 || gw.settleN != 1 || gw.releaseN != 0 { + t.Fatalf("upstream=%d preflight=%d settle=%d release=%d", upstreamCalls, gw.preflightN, gw.settleN, gw.releaseN) + } + if fac.verifyCalls.Load() != 0 || fac.settleCalls.Load() != 0 { + t.Fatalf("Tempo MPP route must not use x402 facilitator: verify=%d settle=%d", fac.verifyCalls.Load(), fac.settleCalls.Load()) + } +} + +func TestAddMPPAuthenticateHeadersAddsOneHeaderPerRail(t *testing.T) { + t.Setenv(mppChallengeSecretEnv, "test-secret") + rule := cardTestRule() + rule.MPPTempo = tempoTestRule().MPPTempo + rule.Price = "0.50" + r := httptest.NewRequest(http.MethodGet, "/services/card-foo/x", nil) + r.Host = "seller.example" + w := httptest.NewRecorder() + + addMPPAuthenticateHeaders(w, r, rule) + + headers := w.Header().Values("WWW-Authenticate") + if len(headers) != 2 { + t.Fatalf("WWW-Authenticate headers = %v, want two rails", headers) + } + joined := strings.Join(headers, "\n") + if !strings.Contains(joined, `method="stripe"`) || !strings.Contains(joined, `method="tempo"`) { + t.Fatalf("WWW-Authenticate headers = %v, want stripe and tempo methods", headers) + } +} + +func TestMPPAuthorizationMethod(t *testing.T) { + t.Setenv(mppChallengeSecretEnv, "test-secret") + stripeChallenge, err := stripeMPPChallenge(cardTestRule(), "seller.example", mppChallengeSecret()) + if err != nil { + t.Fatalf("stripeMPPChallenge: %v", err) + } + tempoChallenge, err := tempoMPPChallenge(tempoTestRule(), "seller.example", mppChallengeSecret()) + if err != nil { + t.Fatalf("tempoMPPChallenge: %v", err) + } + tests := []struct { + name string + auth string + want string + }{ + {name: "stripe", auth: stripeChallenge.NewCredential(map[string]any{"spt": "spt_123"}).ToAuthorization(), want: mppMethodStripe}, + {name: "tempo", auth: tempoChallenge.NewCredential(map[string]any{"type": string(tempo.CredentialTypeTransaction), "signature": "0x76"}).ToAuthorization(), want: mppMethodTempo}, + {name: "missing", auth: "", want: ""}, + {name: "wrong scheme", auth: "Bearer token", want: ""}, + {name: "bad payment credential", auth: "Payment not-a-credential", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + if tt.auth != "" { + r.Header.Set("Authorization", tt.auth) + } + if got := mppAuthorizationMethod(r); got != tt.want { + t.Fatalf("mppAuthorizationMethod() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestTempoMPPPreflightRejectsPushHashCredential(t *testing.T) { + t.Setenv(mppChallengeSecretEnv, "test-secret") + rule := tempoTestRule() + challenge, err := tempoMPPChallenge(rule, "seller.example", mppChallengeSecret()) + if err != nil { + t.Fatalf("tempoMPPChallenge: %v", err) + } + auth := challenge.NewCredential(map[string]any{"type": string(tempo.CredentialTypeHash), "hash": "0xabc"}).ToAuthorization() + r := httptest.NewRequest(http.MethodPost, "/services/tempo-foo/x", nil) + r.Host = "seller.example" + r.Header.Set("Authorization", auth) + + gw := newTempoMPPGateway() + if _, err := gw.preflight(r, rule); err == nil { + t.Fatal("expected hash/push credential to be rejected") + } +} + +func TestTempoMPPPreflightRejectsBadTransactionCredential(t *testing.T) { + t.Setenv(mppChallengeSecretEnv, "test-secret") + rule := tempoTestRule() + challenge, err := tempoMPPChallenge(rule, "seller.example", mppChallengeSecret()) + if err != nil { + t.Fatalf("tempoMPPChallenge: %v", err) + } + tests := []struct { + name string + payload map[string]any + want string + }{ + { + name: "missing signature", + payload: map[string]any{"type": string(tempo.CredentialTypeTransaction)}, + want: "missing signature", + }, + { + name: "invalid serialized transaction", + payload: map[string]any{"type": string(tempo.CredentialTypeTransaction), "signature": "0x76zz"}, + want: "invalid Tempo transaction payload", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/services/tempo-foo/x", nil) + r.Host = "seller.example" + r.Header.Set("Authorization", challenge.NewCredential(tt.payload).ToAuthorization()) + _, err := newTempoMPPGateway().preflight(r, rule) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("preflight err=%v, want substring %q", err, tt.want) + } + }) + } +} + +func TestTempoMPPGatewayReleaseAndSettleErrors(t *testing.T) { + gw := newTempoMPPGateway() + gw.reserved["challenge-1"] = time.Now() + gw.release(&tempoMPPAuthorization{ChallengeID: "challenge-1"}) + if _, exists := gw.reserved["challenge-1"]; exists { + t.Fatal("release should remove reserved challenge") + } + gw.release(nil) + + if receipt, err := gw.settle(context.Background(), nil, tempoTestRule()); err == nil || receipt != nil { + t.Fatalf("settle(nil) receipt=%v err=%v, want error", receipt, err) + } + if receipt, err := gw.settle(context.Background(), &tempoMPPAuthorization{ + Authorization: "Payment invalid", + Realm: "seller.example", + }, tempoTestRule()); err == nil || receipt != nil { + t.Fatalf("settle(invalid auth) receipt=%v err=%v, want error", receipt, err) + } +} diff --git a/internal/x402/serviceoffer_source.go b/internal/x402/serviceoffer_source.go index 2983f5ba..5bb99ac6 100644 --- a/internal/x402/serviceoffer_source.go +++ b/internal/x402/serviceoffer_source.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "fmt" "log" + "os" "sort" "strings" "time" @@ -181,6 +182,53 @@ func routeRuleFromOffer(offer *monetizeapi.ServiceOffer, upstreamAuth string) (R MaxTimeoutSeconds: offer.Spec.Payment.MaxTimeoutSeconds, } + // 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 + } + decimals := currencyMinorUnits(currency) + if err := validateStripeCardMinimum(price, currency, decimals); err != nil { + return RouteRule{}, err + } + provider := c.Provider + if provider == "" { + provider = cardNetworkStripe + } + profileID := firstNonEmpty(c.ProfileID, os.Getenv("STRIPE_PROFILE_ID")) + if profileID != "" && !validStripeProfileID(profileID) { + return RouteRule{}, fmt.Errorf("invalid Stripe profile id %q: expected profile_... or profile_test_...", profileID) + } + rule.Card = &CardRoute{ + Provider: provider, + Account: c.Account, + Currency: currency, + Decimals: decimals, + ProfileID: profileID, + PaymentMethodTypes: append([]string(nil), c.PaymentMethodTypes...), + } + } + + if offer.Spec.Payment.MPP != nil && offer.Spec.Payment.MPP.Tempo != nil { + t := offer.Spec.Payment.MPP.Tempo + decimals := int(t.Decimals) + if decimals == 0 { + decimals = 6 + } + rule.MPPTempo = &TempoMPPRoute{ + PayTo: t.PayTo, + Asset: t.Asset, + Decimals: decimals, + ChainID: t.ChainID, + Network: t.Network, + } + } + 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 6865fe10..a01886df 100644 --- a/internal/x402/serviceoffer_source_test.go +++ b/internal/x402/serviceoffer_source_test.go @@ -237,6 +237,68 @@ func TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds(t *testing.T) { } } +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", + ProfileID: "profile_test_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.ProfileID != "profile_test_1" { + t.Errorf("profile id = %q, want profile_test_1", route.Card.ProfileID) + } + // 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 29f4451d..8feeca8b 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -183,10 +183,13 @@ func (v *Verifier) HandleVerify(w http.ResponseWriter, r *http.Request) { display := buildPaymentDisplay(rule, chain, asset, wallet, requirement.Amount) middleware := NewForwardAuthMiddleware(ForwardAuthConfig{ - FacilitatorURL: cfg.FacilitatorURL, - VerifyOnly: cfg.VerifyOnly, - Extensions: extensions, - SendPaymentRequired: NewHTMLAwarePaymentRequired(display), + FacilitatorURL: cfg.FacilitatorURL, + VerifyOnly: cfg.VerifyOnly, + Extensions: extensions, + SendPaymentRequired: func(w http.ResponseWriter, r *http.Request, requirements []x402types.PaymentRequirements, extensions map[string]any) { + addMPPAuthenticateHeaders(w, r, rule) + NewHTMLAwarePaymentRequired(display)(w, r, requirements, extensions) + }, }, []x402types.PaymentRequirements{requirement}) upstreamAuth := rule.UpstreamAuth @@ -234,6 +237,17 @@ func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { return } + // MPP credit-card offers gate through Stripe (authorize -> capture/cancel) + // instead of the x402 facilitator ForwardAuth path. + if rule.MPPTempo != nil && mppAuthorizationMethod(r) == mppMethodTempo { + v.serveTempoMPPGated(w, r, rule, requirement, extensions, proxy, defaultTempoMPPGateway) + return + } + if rule.IsCard() { + v.serveCardGated(w, r, rule, requirement, extensions, proxy, defaultCardGateway, defaultSPTGuard) + return + } + wallet := cfg.Wallet if rule.PayTo != "" { wallet = rule.PayTo @@ -246,10 +260,13 @@ func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { // upstream and settles only after a <400 response, so verifyOnly=false // is correct here. SettlesInProcess suppresses the (otherwise // per-request) verifyOnly=false warning on this safe path. - VerifyOnly: false, - SettlesInProcess: true, - Extensions: extensions, - SendPaymentRequired: NewHTMLAwarePaymentRequired(display), + VerifyOnly: false, + SettlesInProcess: true, + Extensions: extensions, + SendPaymentRequired: func(w http.ResponseWriter, r *http.Request, requirements []x402types.PaymentRequirements, extensions map[string]any) { + addMPPAuthenticateHeaders(w, r, rule) + NewHTMLAwarePaymentRequired(display)(w, r, requirements, extensions) + }, }, []x402types.PaymentRequirements{requirement}) hadPayment := r.Header.Get("X-PAYMENT") != "" @@ -313,6 +330,12 @@ func (v *Verifier) matchPaidRouteFull(cfg *PricingConfig, uri string) (*RouteRul return nil, x402types.PaymentRequirements{}, nil, nil, ChainInfo{}, AssetInfo{}, false } + // 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