Skip to content

Commit 1cc3dd8

Browse files
committed
feat(x402): add Hermes MPP and Stripe compatibility
1 parent eecb83a commit 1cc3dd8

23 files changed

Lines changed: 2622 additions & 136 deletions

.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,16 @@ ANTHROPIC_API_KEY=
1111

1212
# Required for TestIntegration_OpenAIInference
1313
OPENAI_API_KEY=
14+
15+
# ── MPP credit-card payments (Stripe) ──────────────────────────────────────
16+
# Seller-side credit-card settlement via the Machine Payments Protocol (MPP).
17+
# Requires a Stripe account with "Machine payments" enabled. See the
18+
# "Credit-card payments (MPP)" section of README.md.
19+
#
20+
# Consumed by the x402-verifier (sourced from the x402-secrets Secret in the
21+
# `x402` namespace) to authorize/capture Stripe PaymentIntents for card offers.
22+
STRIPE_SECRET_KEY=
23+
# Your Stripe "machine payments" network id, advertised in the 402 challenge so
24+
# card clients can mint a Shared Payment Token. Default for
25+
# `obol sell http --pay-with card --stripe-network-id`.
26+
STRIPE_NETWORK_ID=

README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,60 @@ obol openclaw skills remove <name> # remove via openclaw CLI in pod
232232

233233
Skills are delivered via host-path PVC injection — no ConfigMap size limits, works before pod readiness, and survives pod restarts.
234234

235+
## Credit-card payments (MPP)
236+
237+
Alongside the default x402 on-chain (stablecoin) payment path, sellers can accept
238+
**credit-card** payments via the [Machine Payments Protocol](https://mpp.dev) (MPP,
239+
the Stripe + Tempo HTTP-402 standard). A card offer is gated on the same
240+
`/services/<name>/*` route as a crypto offer — the payment method is selected per
241+
offer.
242+
243+
```bash
244+
# Expose an upstream as a card-paid endpoint (Stripe stripe.charge).
245+
obol sell http my-api \
246+
--pay-with card \
247+
--stripe-account acct_1A2b3C4d \ # Stripe destination account (card analog of --pay-to)
248+
--stripe-network-id stripenet_...\ # Stripe "machine payments" network id (or STRIPE_NETWORK_ID)
249+
--card-currency usd \
250+
--upstream my-svc --port 8080 --price 0.01
251+
```
252+
253+
How it works:
254+
255+
- The offer advertises a `card` option in its `402` challenge (amount in the
256+
currency's **minor units** — cents for `usd`, whole yen for `jpy`, etc.).
257+
- A card-capable buyer presents a Stripe **Shared Payment Token** (`spt_…`) in the
258+
`X-PAYMENT` header.
259+
- The verifier **authorizes** a manual-capture Stripe PaymentIntent before serving,
260+
proxies to the upstream, then **captures** only after a successful (`<400`)
261+
response — a failed upstream **cancels** the hold, so a buyer is never charged for
262+
nothing. Each SPT is single-use (replay-guarded).
263+
264+
### Requirements & configuration
265+
266+
- A **Stripe account with "Machine payments" enabled** (a gated Stripe feature).
267+
- `STRIPE_SECRET_KEY` — used by the `x402-verifier` to authorize/capture
268+
PaymentIntents. It is read from the `x402-secrets` Secret in the `x402` namespace;
269+
populate it before taking card payments:
270+
271+
```bash
272+
kubectl -n x402 patch secret x402-secrets --type merge \
273+
-p '{"stringData":{"STRIPE_SECRET_KEY":"sk_live_..."}}'
274+
kubectl -n x402 rollout restart deploy/x402-verifier
275+
```
276+
277+
- `STRIPE_NETWORK_ID` — your Stripe "machine payments" network id, advertised in the
278+
402 challenge so clients can mint an SPT. It is a host/CLI value (default for
279+
`--stripe-network-id`); add both to your `.env` from `.env.example`.
280+
281+
> **Note on scope.** Card offers are not ERC-8004 registered (no on-chain identity).
282+
> The Stripe key is currently a single cluster-wide value in `x402-secrets`; a
283+
> per-offer/per-namespace Secret is the production direction but is gated on widening
284+
> the verifier's deliberately `resourceName`-scoped Secret RBAC. The SPT replay guard
285+
> is per-pod (the verifier runs single-replica). The SPT is passed as the top-level
286+
> Stripe form field `shared_payment_granted_token` per the `cp0x-org/mppx` reference —
287+
> validate against your live Stripe account before relying on it in production.
288+
235289
## Public Access (Cloudflare Tunnel)
236290

237291
A tunnel exposes your stack to the public internet so buyers can discover and

cmd/obol/sell.go

Lines changed: 171 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"os/exec"
1717
"os/signal"
1818
"path/filepath"
19+
"regexp"
1920
"runtime"
2021
"strconv"
2122
"strings"
@@ -86,6 +87,67 @@ func payToFlag(usage string) *cli.StringFlag {
8687
}
8788
}
8889

90+
// Payment-method selector values for the --pay-with flag.
91+
const (
92+
payMethodCrypto = "crypto"
93+
payMethodCard = "card"
94+
)
95+
96+
var (
97+
// stripeAccountRe matches a Stripe account id (e.g. acct_1A2b3C4d).
98+
stripeAccountRe = regexp.MustCompile(`^acct_[A-Za-z0-9]+$`)
99+
// currencyRe matches a lower-case ISO-4217 currency code (e.g. usd).
100+
currencyRe = regexp.MustCompile(`^[a-z]{3}$`)
101+
)
102+
103+
// normalizePayWith lower-cases/trims the --pay-with value and defaults an
104+
// empty value to crypto so existing flag-free invocations are unchanged.
105+
func normalizePayWith(v string) string {
106+
v = strings.ToLower(strings.TrimSpace(v))
107+
if v == "" {
108+
return payMethodCrypto
109+
}
110+
return v
111+
}
112+
113+
// resolveCardPayment validates the card flags and returns the
114+
// spec.payment map for an MPP credit-card (Stripe) ServiceOffer. It is the
115+
// card analog of the crypto wallet/chain/asset resolution in the sell
116+
// actions: instead of a chain + 0x payTo it emits method=card plus a card
117+
// block carrying the Stripe destination account and currency.
118+
func resolveCardPayment(cmd *cli.Command, price map[string]any) (map[string]any, error) {
119+
account := strings.TrimSpace(cmd.String("stripe-account"))
120+
if account == "" {
121+
return nil, fmt.Errorf("--stripe-account is required with --pay-with card (the acct_... that receives card funds)")
122+
}
123+
if !stripeAccountRe.MatchString(account) {
124+
return nil, fmt.Errorf("invalid --stripe-account %q: expected a Stripe account id like acct_1A2b3C4d", account)
125+
}
126+
currency := strings.ToLower(strings.TrimSpace(cmd.String("card-currency")))
127+
if currency == "" {
128+
currency = "usd"
129+
}
130+
if !currencyRe.MatchString(currency) {
131+
return nil, fmt.Errorf("invalid --card-currency %q: expected a 3-letter ISO-4217 code like usd", currency)
132+
}
133+
card := map[string]any{
134+
"provider": "stripe",
135+
"account": account,
136+
"currency": currency,
137+
}
138+
// Stripe MPP uses a profile id (profile_... / profile_test_...) so card
139+
// clients can mint a Shared Payment Token.
140+
if profileID := strings.TrimSpace(cmd.String("stripe-profile-id")); profileID != "" {
141+
card["profileId"] = profileID
142+
}
143+
return map[string]any{
144+
"method": payMethodCard,
145+
"card": card,
146+
"maxTimeoutSeconds": cmd.Int("max-timeout"),
147+
"price": price,
148+
}, nil
149+
}
150+
89151
// ---------------------------------------------------------------------------
90152
// sell inference — start a local x402 gateway for LLM inference
91153
// ---------------------------------------------------------------------------
@@ -706,6 +768,25 @@ Examples:
706768
Usage: "Target namespace for the ServiceOffer",
707769
Value: "default",
708770
},
771+
&cli.StringFlag{
772+
Name: "pay-with",
773+
Usage: "Payment method: 'crypto' (x402 on-chain stablecoin, default) or 'card' (MPP Stripe credit card)",
774+
Value: payMethodCrypto,
775+
},
776+
&cli.StringFlag{
777+
Name: "stripe-account",
778+
Usage: "Stripe destination account id (acct_...) that receives card funds — required with --pay-with card (card analog of --pay-to)",
779+
},
780+
&cli.StringFlag{
781+
Name: "card-currency",
782+
Usage: "ISO-4217 currency for card charges",
783+
Value: "usd",
784+
},
785+
&cli.StringFlag{
786+
Name: "stripe-profile-id",
787+
Usage: "Stripe profile id (profile_... or profile_test_...) advertised in the MPP challenge",
788+
Sources: cli.EnvVars("STRIPE_PROFILE_ID"),
789+
},
709790
&cli.StringFlag{
710791
Name: "upstream",
711792
Usage: "Upstream service name",
@@ -840,32 +921,17 @@ Examples:
840921
return err
841922
}
842923

843-
// Auto-discover wallet from remote-signer if not set.
844-
wallet := cmd.String("pay-to")
845-
if wallet == "" {
846-
if resolved, err := hermes.ResolveWalletAddress(cfg); err == nil {
847-
wallet = resolved
848-
u.Infof("Using wallet from remote-signer: %s", wallet)
849-
} else if u.IsTTY() {
850-
var inputErr error
851-
wallet, inputErr = u.Input("Wallet address (payment recipient)", "")
852-
if inputErr != nil || wallet == "" {
853-
return fmt.Errorf("recipient required: use --pay-to <addr> or set X402_WALLET")
854-
}
855-
} else {
856-
return fmt.Errorf("recipient required: use --pay-to <addr> or set X402_WALLET")
857-
}
858-
}
859-
if err := x402verifier.ValidateWallet(wallet); err != nil {
860-
return err
861-
}
862-
863-
// Ensure the x402-verifier CA bundle is populated so TLS verification of
864-
// the facilitator works. This is a no-op if already populated. Non-fatal.
865-
x402verifier.PopulateCABundle(cfg)
866-
867924
ns := cmd.String("namespace")
868925

926+
payWith := normalizePayWith(cmd.String("pay-with"))
927+
if payWith != payMethodCrypto && payWith != payMethodCard {
928+
return fmt.Errorf("--pay-with must be %q or %q, got %q", payMethodCrypto, payMethodCard, cmd.String("pay-with"))
929+
}
930+
isCard := payWith == payMethodCard
931+
// wallet is the crypto payTo recipient; resolved in the crypto
932+
// branch below and left empty for card offers.
933+
var wallet string
934+
869935
if cmd.String("upstream") == "" {
870936
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)
871937
}
@@ -889,10 +955,59 @@ Examples:
889955
price["perHour"] = priceTable.PerHour
890956
}
891957

892-
chainName := cmd.String("chain")
893-
assetTerms, err := resolveAssetTerms(cmd, &chainName)
894-
if err != nil {
895-
return err
958+
// Resolve the payment block per the selected method.
959+
var (
960+
payment map[string]any
961+
assetTerms schemas.AssetTerms // crypto only; stays zero for card
962+
)
963+
switch payWith {
964+
case payMethodCard:
965+
payment, err = resolveCardPayment(cmd, price)
966+
if err != nil {
967+
return err
968+
}
969+
u.Infof("Selling via credit card (Stripe account %s, %s)",
970+
cmd.String("stripe-account"), strings.ToLower(cmd.String("card-currency")))
971+
default: // payMethodCrypto
972+
// Auto-discover wallet from remote-signer if not set.
973+
wallet = cmd.String("pay-to")
974+
if wallet == "" {
975+
if resolved, rerr := hermes.ResolveWalletAddress(cfg); rerr == nil {
976+
wallet = resolved
977+
u.Infof("Using wallet from remote-signer: %s", wallet)
978+
} else if u.IsTTY() {
979+
var inputErr error
980+
wallet, inputErr = u.Input("Wallet address (payment recipient)", "")
981+
if inputErr != nil || wallet == "" {
982+
return fmt.Errorf("recipient required: use --pay-to <addr> or set X402_WALLET")
983+
}
984+
} else {
985+
return fmt.Errorf("recipient required: use --pay-to <addr> or set X402_WALLET")
986+
}
987+
}
988+
if err := x402verifier.ValidateWallet(wallet); err != nil {
989+
return err
990+
}
991+
// Ensure the x402-verifier CA bundle is populated so TLS
992+
// verification of the facilitator works. No-op if already
993+
// populated. Non-fatal.
994+
x402verifier.PopulateCABundle(cfg)
995+
996+
chainName := cmd.String("chain")
997+
assetTerms, err = resolveAssetTerms(cmd, &chainName)
998+
if err != nil {
999+
return err
1000+
}
1001+
payment = map[string]any{
1002+
"scheme": "exact",
1003+
"network": chainName,
1004+
"payTo": wallet,
1005+
"maxTimeoutSeconds": cmd.Int("max-timeout"),
1006+
"price": price,
1007+
}
1008+
if !assetTerms.IsZero() {
1009+
payment["asset"] = assetTerms
1010+
}
8961011
}
8971012

8981013
spec := map[string]any{
@@ -903,16 +1018,7 @@ Examples:
9031018
"port": cmd.Int("port"),
9041019
"healthPath": cmd.String("health-path"),
9051020
},
906-
"payment": map[string]any{
907-
"scheme": "exact",
908-
"network": chainName,
909-
"payTo": wallet,
910-
"maxTimeoutSeconds": cmd.Int("max-timeout"),
911-
"price": price,
912-
},
913-
}
914-
if !assetTerms.IsZero() {
915-
spec["payment"].(map[string]any)["asset"] = assetTerms
1021+
"payment": payment,
9161022
}
9171023

9181024
if path := cmd.String("path"); path != "" {
@@ -941,21 +1047,33 @@ Examples:
9411047
prov.Framework, prov.MetricName, prov.MetricValue, prov.ParamCount)
9421048
}
9431049

944-
reg, registerEnabled, err := buildSellRegistrationConfig(name, sellRegistrationInput{
945-
NoRegister: cmd.Bool("no-register"),
946-
Register: cmd.Bool("register"),
947-
Name: cmd.String("register-name"),
948-
Description: cmd.String("description"),
949-
Image: cmd.String("register-image"),
950-
Skills: cmd.StringSlice("register-skills"),
951-
Domains: cmd.StringSlice("register-domains"),
952-
MetadataPairs: cmd.StringSlice("register-metadata"),
953-
})
954-
if err != nil {
955-
return err
956-
}
957-
if registerEnabled {
958-
spec["registration"] = reg
1050+
// ERC-8004 registration is an on-chain identity step and only
1051+
// applies to crypto offers. Card offers publish the payment-gated
1052+
// route without registration.
1053+
var registerEnabled bool
1054+
if isCard {
1055+
if cmd.Bool("register") {
1056+
return fmt.Errorf("ERC-8004 registration is not supported for --pay-with card yet; re-run with --no-register")
1057+
}
1058+
u.Info("Card offers are not ERC-8004 registered (no on-chain identity); publishing the payment-gated route only.")
1059+
} else {
1060+
reg, enabled, rerr := buildSellRegistrationConfig(name, sellRegistrationInput{
1061+
NoRegister: cmd.Bool("no-register"),
1062+
Register: cmd.Bool("register"),
1063+
Name: cmd.String("register-name"),
1064+
Description: cmd.String("description"),
1065+
Image: cmd.String("register-image"),
1066+
Skills: cmd.StringSlice("register-skills"),
1067+
Domains: cmd.StringSlice("register-domains"),
1068+
MetadataPairs: cmd.StringSlice("register-metadata"),
1069+
})
1070+
if rerr != nil {
1071+
return rerr
1072+
}
1073+
registerEnabled = enabled
1074+
if registerEnabled {
1075+
spec["registration"] = reg
1076+
}
9591077
}
9601078

9611079
// When registration is enabled, the serviceoffer-controller reads the

0 commit comments

Comments
 (0)