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
213 changes: 160 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 @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 <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 @@ -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 <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 @@ -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 != "" {
Expand Down Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions cmd/obol/sell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading