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)

Expose your stack to the internet via Cloudflare Tunnel:
Expand Down
24 changes: 18 additions & 6 deletions cmd/obol/sell.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,20 @@ func resolveCardPayment(cmd *cli.Command, price map[string]any) (map[string]any,
if !currencyRe.MatchString(currency) {
return nil, fmt.Errorf("invalid --card-currency %q: expected a 3-letter ISO-4217 code like usd", currency)
}
card := map[string]any{
"provider": "stripe",
"account": account,
"currency": currency,
}
// The Stripe "machine payments" network id is advertised in the 402
// challenge so MPP card clients can mint a Shared Payment Token. Defaults
// from the STRIPE_NETWORK_ID env var.
if networkID := strings.TrimSpace(cmd.String("stripe-network-id")); networkID != "" {
card["networkId"] = networkID
}
return map[string]any{
"method": payMethodCard,
"card": map[string]any{
"provider": "stripe",
"account": account,
"currency": currency,
},
"method": payMethodCard,
"card": card,
"maxTimeoutSeconds": cmd.Int("max-timeout"),
"price": price,
}, nil
Expand Down Expand Up @@ -647,6 +654,11 @@ Examples:
Usage: "ISO-4217 currency for card charges",
Value: "usd",
},
&cli.StringFlag{
Name: "stripe-network-id",
Usage: "Stripe \"machine payments\" network id advertised in the 402 challenge (so MPP card clients can mint a Shared Payment Token)",
Sources: cli.EnvVars("STRIPE_NETWORK_ID"),
},
&cli.StringFlag{
Name: "upstream",
Usage: "Upstream service name",
Expand Down
8 changes: 6 additions & 2 deletions cmd/obol/sell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ func TestSellHTTP_Flags(t *testing.T) {
"namespace", "upstream", "port", "health-path", "path",
"max-timeout",
"register", "no-register", "register-name", "register-description", "register-image",
"pay-with", "stripe-account", "card-currency",
"pay-with", "stripe-account", "card-currency", "stripe-network-id",
)

assertStringDefault(t, flags, "chain", "base")
Expand Down Expand Up @@ -306,6 +306,7 @@ func runCardResolve(t *testing.T, args ...string) (map[string]any, error) {
&cli.StringFlag{Name: "pay-with", Value: payMethodCard},
&cli.StringFlag{Name: "stripe-account"},
&cli.StringFlag{Name: "card-currency", Value: "usd"},
&cli.StringFlag{Name: "stripe-network-id"},
&cli.IntFlag{Name: "max-timeout", Value: 300},
},
Action: func(_ context.Context, c *cli.Command) error {
Expand All @@ -320,7 +321,7 @@ func runCardResolve(t *testing.T, args ...string) (map[string]any, error) {
}

func TestResolveCardPayment_Valid(t *testing.T) {
out, err := runCardResolve(t, "--stripe-account", "acct_1A2b3C4d", "--card-currency", "eur")
out, err := runCardResolve(t, "--stripe-account", "acct_1A2b3C4d", "--card-currency", "eur", "--stripe-network-id", "stripenet_test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -340,6 +341,9 @@ func TestResolveCardPayment_Valid(t *testing.T) {
if card["currency"] != "eur" {
t.Errorf("card.currency = %v, want eur", card["currency"])
}
if card["networkId"] != "stripenet_test" {
t.Errorf("card.networkId = %v, want stripenet_test", card["networkId"])
}
if _, ok := out["price"].(map[string]any); !ok {
t.Errorf("price block missing: %v", out["price"])
}
Expand Down
16 changes: 16 additions & 0 deletions internal/embed/infrastructure/base/templates/x402.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ metadata:
type: Opaque
stringData:
WALLET_ADDRESS: ""
# Stripe secret key for MPP credit-card (stripe.charge) offers. Empty on a
# crypto-only stack; populate it (e.g. `kubectl -n x402 patch secret
# x402-secrets --type merge -p '{"stringData":{"STRIPE_SECRET_KEY":"sk_live_..."}}'`)
# to let the verifier authorize/capture card PaymentIntents. See README
# "Credit-card payments (MPP)".
STRIPE_SECRET_KEY: ""

---
apiVersion: v1
Expand Down Expand Up @@ -267,6 +273,16 @@ spec:
- --config=/config/pricing.yaml
- --listen=:8080
- --route-source=kube
env:
# MPP credit-card (Stripe) settlement key. Empty unless card offers
# are in use; optional=true keeps the verifier starting on
# crypto-only stacks where the key is unset.
- name: STRIPE_SECRET_KEY
valueFrom:
secretKeyRef:
name: x402-secrets
key: STRIPE_SECRET_KEY
optional: true
volumeMounts:
- name: pricing-config
mountPath: /config
Expand Down
Loading