main
go 194 lines 6.59 KB
Raw
1 package main
2
3 import (
4 "context"
5 "errors"
6 "flag"
7 "fmt"
8 "io"
9 "os"
10 "strings"
11 "time"
12
13 "github.com/rs/zerolog"
14 "github.com/rs/zerolog/log"
15
16 "github.com/gosuda/portal-tunnel/v2/sdk"
17 "github.com/gosuda/portal-tunnel/v2/types"
18 "github.com/gosuda/portal-tunnel/v2/utils"
19 )
20
21 const (
22 defaultThumbnailURL = "https://image.portal.thumbgo.kr/generated/1e56ad0f0a1d.png"
23 defaultPhotoURL = "https://image.portal.thumbgo.kr/generated/905a4835ad50.png"
24 )
25
26 type paymentConfig struct {
27 relayURLs string
28 discovery bool
29 banMITM bool
30 identityPath string
31 identityJSON string
32 addr string
33 name string
34 desc string
35 tags string
36 owner string
37 hide bool
38 thumbnail string
39 photoURL string
40 maxActiveRelays int
41
42 x402Testnet bool
43 x402PayTo string
44 x402Amount string
45 x402Endpoints []string
46 x402MaxTimeoutSeconds int
47 x402RequestTimeout int
48 }
49
50 func main() {
51 log.Logger = log.Output(zerolog.NewConsoleWriter())
52 if err := run(os.Args[1:]); err != nil {
53 log.Error().Err(err).Msg("payment app failed")
54 os.Exit(1)
55 }
56 }
57
58 func run(args []string) error {
59 cfg := paymentConfig{}
60 fs := utils.NewFlagSet("payment-app", printUsage)
61
62 utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://localhost", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with bootstrap relays when discovery is enabled)", "RELAYS")
63 utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", false, "include bootstrap relays and enable discovery", "DISCOVERY")
64 utils.BoolFlagEnv(fs, &cfg.banMITM, "ban-mitm", false, "ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
65 utils.StringFlagEnv(fs, &cfg.identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH")
66 utils.StringFlagEnv(fs, &cfg.identityJSON, "identity-json", "", "identity json payload; overrides --identity-path contents and is persisted there when both are set", "IDENTITY_JSON")
67 utils.IntFlagEnv(fs, &cfg.maxActiveRelays, "max-active-relays", 3, nil, "maximum number of auto-selected relays to keep connected; explicit --relays are always included", "MAX_ACTIVE_RELAYS")
68 utils.StringFlag(fs, &cfg.addr, "addr", "127.0.0.1:8093", "local payment app HTTP listen address (host:port or URL)")
69 utils.StringFlag(fs, &cfg.name, "name", "payment-app", "public hostname prefix (single DNS label)")
70 utils.StringFlag(fs, &cfg.desc, "description", "Portal Sui wallet x402 payment app", "lease description")
71 utils.StringFlag(fs, &cfg.tags, "tags", "payment,x402,sui,usdc,image,photo", "comma-separated lease tags")
72 utils.StringFlag(fs, &cfg.owner, "owner", "PortalApp Developer", "lease owner")
73 utils.StringFlag(fs, &cfg.thumbnail, "thumbnail", defaultThumbnailURL, "lease thumbnail")
74 utils.StringFlag(fs, &cfg.photoURL, "photo-url", defaultPhotoURL, "image URL revealed after payment")
75 utils.BoolFlag(fs, &cfg.hide, "hide", false, "hide this lease from listings")
76 utils.BoolFlag(fs, &cfg.x402Testnet, "x402-testnet", true, "use Sui testnet for x402 payments")
77 utils.StringFlag(fs, &cfg.x402PayTo, "x402-pay-to", "", "Sui USDC recipient address")
78 utils.StringFlag(fs, &cfg.x402Amount, "x402-amount", "0.01", "USDC amount")
79 utils.RepeatedStringFlag(fs, &cfg.x402Endpoints, "x402-rpc", "Sui gRPC endpoint; repeat to try multiple endpoints before defaults")
80 fs.IntVar(&cfg.x402MaxTimeoutSeconds, "x402-max-timeout", 0, "x402 max payment timeout seconds advertised to clients")
81 fs.IntVar(&cfg.x402RequestTimeout, "x402-request-timeout", 30, "Sui gRPC and x402 verify/settle timeout seconds")
82
83 if err := utils.ParseFlagSet(fs, args, printUsage); err != nil {
84 if errors.Is(err, flag.ErrHelp) {
85 return nil
86 }
87 return err
88 }
89 if err := utils.RequireNoArgs(fs.Args(), "payment-app"); err != nil {
90 printUsage(os.Stderr)
91 return err
92 }
93 normalizedName, err := utils.NormalizeDNSLabel(cfg.name)
94 if err != nil {
95 return fmt.Errorf("invalid --name value: %w", err)
96 }
97 cfg.name = normalizedName
98 if err := validatePaymentConfig(cfg); err != nil {
99 return err
100 }
101
102 ctx, stop := utils.SignalContext()
103 defer stop()
104
105 return runPaymentApp(ctx, cfg)
106 }
107
108 func validatePaymentConfig(cfg paymentConfig) error {
109 switch {
110 case strings.TrimSpace(cfg.x402PayTo) == "":
111 return errors.New("--x402-pay-to is required")
112 case strings.TrimSpace(cfg.x402Amount) == "":
113 return errors.New("--x402-amount is required")
114 case strings.TrimSpace(cfg.photoURL) == "":
115 return errors.New("--photo-url is required")
116 case cfg.x402MaxTimeoutSeconds < 0:
117 return errors.New("--x402-max-timeout cannot be negative")
118 case cfg.x402RequestTimeout < 0:
119 return errors.New("--x402-request-timeout cannot be negative")
120 default:
121 return nil
122 }
123 }
124
125 func runPaymentApp(ctx context.Context, cfg paymentConfig) error {
126 metadata := types.LeaseMetadata{
127 Description: cfg.desc,
128 Tags: utils.SplitCSV(cfg.tags),
129 Owner: cfg.owner,
130 Thumbnail: cfg.thumbnail,
131 Hide: cfg.hide,
132 }
133 rawAddr := cfg.addr
134 addr, err := utils.NormalizeTargetAddr(cfg.addr)
135 if err != nil {
136 return fmt.Errorf("invalid --addr value %q: %w", rawAddr, err)
137 }
138
139 handler, err := newHandler(paymentHandlerConfig{
140 Metadata: metadata,
141 Testnet: cfg.x402Testnet,
142 PayTo: cfg.x402PayTo,
143 Amount: cfg.x402Amount,
144 MaxTimeoutSeconds: cfg.x402MaxTimeoutSeconds,
145 RequestTimeout: time.Duration(cfg.x402RequestTimeout) * time.Second,
146 Endpoints: cfg.x402Endpoints,
147 PhotoURL: cfg.photoURL,
148 })
149 if err != nil {
150 return err
151 }
152
153 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
154 RelayURLs: utils.SplitCSV(cfg.relayURLs),
155 Discovery: cfg.discovery,
156 Identity: types.Identity{Name: cfg.name},
157 IdentityPath: cfg.identityPath,
158 IdentityJSON: cfg.identityJSON,
159 BanMITM: cfg.banMITM,
160 MaxActiveRelays: cfg.maxActiveRelays,
161 Metadata: metadata,
162 })
163 if err != nil {
164 return fmt.Errorf("exposure listen error: %w", err)
165 }
166 defer exposure.Close()
167
168 err = exposure.RunHTTP(ctx, handler, addr)
169 if err != nil {
170 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
171 err = nil
172 }
173 return err
174 }
175
176 if ctx.Err() != nil {
177 log.Info().Msg("payment app shutting down")
178 }
179 log.Info().Msg("payment app shutdown complete")
180 return nil
181 }
182
183 func printUsage(w io.Writer) {
184 utils.WriteCommandUsage(w,
185 []string{
186 "payment-app --x402-pay-to SUI_ADDRESS [flags]",
187 },
188 []string{
189 "payment-app --x402-pay-to 0x...",
190 "payment-app --name paid-photo --x402-pay-to 0x... --x402-amount 0.01",
191 "payment-app --x402-testnet=false --x402-pay-to 0x... --x402-amount 0.01",
192 },
193 )
194 }