@@ -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