Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,60 @@ obol openclaw skills remove <name> # 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/<name>/*` 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
Expand Down
224 changes: 171 additions & 53 deletions cmd/obol/sell.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 <addr> or set X402_WALLET")
}
} else {
return fmt.Errorf("recipient required: use --pay-to <addr> 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 <service-name>\n\n Example: obol sell http %s --upstream my-svc --port 8080 --pay-to 0x... --chain base-sepolia --price 0.001", name)
}
Expand All @@ -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 <addr> or set X402_WALLET")
}
} else {
return fmt.Errorf("recipient required: use --pay-to <addr> 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{
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading