From f422bb80d9beb48a8d1253cf529c5a0e0052763a Mon Sep 17 00:00:00 2001 From: bussyjd Date: Mon, 8 Jun 2026 18:06:23 +0400 Subject: [PATCH] feat(sell): add MPP credit-card payment method (CRD + --pay-with card) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a second, payment-method-agnostic gate on ServiceOffer so sellers can accept credit-card payments (MPP / Stripe stripe.charge) alongside the existing x402 on-chain crypto path. This PR lands the CRD surface + CLI flag; the verifier/controller card settlement seam is a separate spike. ServiceOffer CRD (internal/monetizeapi/types.go, regenerated yaml + deepcopy): - Add payment.method discriminator: "crypto" (default, unchanged behavior) | "card". - Add payment.card block (provider=stripe, account=acct_..., currency, networkId, paymentMethodTypes) — the card analog of network/payTo. - payment.payTo / payment.network are no longer unconditionally required; per-method requirements are enforced by three CEL x-kubernetes-validations rules so the API server rejects malformed offers at admission time (payTo+network required for crypto, card.account required for card). The existing payTo 0x-pattern is preserved and only applies when present. CLI (cmd/obol/sell.go): `obol sell http` gains --pay-with (crypto|card), --stripe-account and --card-currency. The card branch skips wallet/chain/asset resolution and ERC-8004 registration (no on-chain identity), emitting a method=card offer. Crypto invocations are byte-for-byte unchanged. schemas.PaymentTerms gains parity Method/Card fields + EffectiveMethod(). Tests: CLI flag/resolver unit tests (cmd/obol), CRD card-schema + CEL test (internal/embed). Smoke-verified on a live k3d v1.35 apiserver: 9/9 admission cases (crypto/card accept+reject) and an end-to-end `obol sell http --pay-with card` round-trip producing a valid stored CR. --- cmd/obol/sell.go | 213 +++++++++++++----- cmd/obol/sell_test.go | 99 ++++++++ internal/embed/embed_crd_test.go | 82 +++++++ .../base/templates/serviceoffer-crd.yaml | 95 +++++++- internal/monetizeapi/types.go | 82 ++++++- internal/monetizeapi/zz_generated.deepcopy.go | 27 ++- internal/schemas/payment.go | 54 ++++- 7 files changed, 575 insertions(+), 77 deletions(-) diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 9bd923a7..02f06790 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" @@ -82,6 +83,61 @@ 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) + } + return map[string]any{ + "method": payMethodCard, + "card": map[string]any{ + "provider": "stripe", + "account": account, + "currency": currency, + }, + "maxTimeoutSeconds": cmd.Int("max-timeout"), + "price": price, + }, nil +} + // --------------------------------------------------------------------------- // sell inference — start a local x402 gateway for LLM inference // --------------------------------------------------------------------------- @@ -577,6 +633,20 @@ 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: "upstream", Usage: "Upstream service name", @@ -708,32 +778,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) } @@ -757,10 +812,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{ @@ -771,16 +875,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 != "" { @@ -809,21 +904,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 55df0346..cb83c980 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -262,16 +262,115 @@ 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", ) 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.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") + 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 _, 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/internal/embed/embed_crd_test.go b/internal/embed/embed_crd_test.go index 3ba0ec21..a891bae6 100644 --- a/internal/embed/embed_crd_test.go +++ b/internal/embed/embed_crd_test.go @@ -241,6 +241,88 @@ 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) + } + } + + // Three CEL rules: payTo-when-crypto, network-when-crypto, card.account-when-card. + rules, ok := payment["x-kubernetes-validations"].([]any) + if !ok { + t.Fatal("payment.x-kubernetes-validations missing") + } + if len(rules) != 3 { + t.Fatalf("payment x-kubernetes-validations count = %d, want 3", 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"} { + 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..3ff58a4c 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,81 @@ 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 + networkId: + description: |- + Optional Stripe "machine payments" network id, surfaced in the 402 + challenge's extra block so MPP card clients know where to mint a + Shared Payment Token. + type: string + paymentMethodTypes: + description: |- + Accepted payment-method types advertised to the client. Defaults to + ["card"] at the gateway when empty. + items: + type: string + maxItems: 16 + type: array + 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 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 +259,23 @@ 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' provenance: additionalProperties: type: string diff --git a/internal/monetizeapi/types.go b/internal/monetizeapi/types.go index db231a71..44f128f6 100644 --- a/internal/monetizeapi/types.go +++ b/internal/monetizeapi/types.go @@ -191,31 +191,90 @@ 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" 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"` + // 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"` + // Optional Stripe "machine payments" network id, surfaced in the 402 + // challenge's extra block so MPP card clients know where to mint a + // Shared Payment Token. + NetworkID string `json:"networkId,omitempty"` + // Accepted payment-method types advertised to the client. Defaults to + // ["card"] at the gateway when empty. + // +kubebuilder:validation:MaxItems=16 + PaymentMethodTypes []string `json:"paymentMethodTypes,omitempty"` +} + type ServiceOfferAsset struct { // ERC-20 contract address. // +kubebuilder:validation:Pattern=`^0x[0-9a-fA-F]{40}$` @@ -723,8 +782,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..3cb82661 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 @@ -621,6 +641,11 @@ 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) + } out.Price = in.Price } @@ -712,7 +737,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)) diff --git a/internal/schemas/payment.go b/internal/schemas/payment.go index 740d1eee..21d96a0c 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,47 @@ 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"` + // 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"` + + // NetworkID is the optional Stripe "machine payments" network id, + // surfaced in the 402 challenge so MPP card clients can mint a token. + NetworkID string `json:"networkId,omitempty" yaml:"networkId,omitempty"` + + // PaymentMethodTypes are the accepted payment-method types advertised to + // the client. Defaults to ["card"] at the gateway when empty. + PaymentMethodTypes []string `json:"paymentMethodTypes,omitempty" yaml:"paymentMethodTypes,omitempty"` +} + const ( AssetTransferMethodEIP3009 = "eip3009" AssetTransferMethodPermit2 = "permit2"