Refactor demo and payment apps: remove x402 support from demo, add payment app with x402 integration

Kim committed May 28, 2026 at 11:05 UTC 26d8b4f0681eadcbddf9ee83bd044fcca51bb58e
13 files changed +921 -330
cmd/demo-app/handler.go
+2 -180
@@ -3,144 +3,19 @@ package main
3 import (
4 "embed"
5 "encoding/json"
6 - "html/template"
6 "io/fs"
7 "net/http"
9 - "strings"
8 "time"
9
10 "golang.org/x/net/websocket"
11
14 - portalx402 "github.com/gosuda/portal-tunnel/v2/portal/x402"
12 "github.com/gosuda/portal-tunnel/v2/sdk"
16 - "github.com/gosuda/portal-tunnel/v2/types"
13 )
14
15 //go:embed static
16 var staticFiles embed.FS
17
22 -const (
23 - premiumPath = "/api/premium"
24 - defaultPremiumCost = "$0.01"
25 - premiumPhotoURL = "https://image.s-h.day/generated/905a4835ad50.png"
26 -)
27 -
28 -var premiumPage = template.Must(template.New("premium").Parse(`<head>
29 - <meta charset="UTF-8">
30 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
31 - <title>Payment Complete</title>
32 - <style>
33 - * { box-sizing: border-box; }
34 - body {
35 - margin: 0;
36 - min-height: 100vh;
37 - display: flex;
38 - align-items: center;
39 - justify-content: center;
40 - padding: 24px;
41 - background: #f6f7f9;
42 - color: #1f2937;
43 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
44 - }
45 - main {
46 - width: min(100%, 560px);
47 - overflow: hidden;
48 - border: 1px solid #e5e7eb;
49 - border-radius: 8px;
50 - background: #ffffff;
51 - box-shadow: 0 12px 32px rgba(17, 24, 39, 0.10);
52 - }
53 - img {
54 - width: 100%;
55 - aspect-ratio: 16 / 9;
56 - display: block;
57 - object-fit: cover;
58 - background: #e5e7eb;
59 - }
60 - section { padding: 22px; }
61 - .eyebrow {
62 - margin: 0 0 8px;
63 - color: #0f766e;
64 - font-size: 13px;
65 - font-weight: 700;
66 - text-transform: uppercase;
67 - letter-spacing: 0;
68 - }
69 - h1 {
70 - margin: 0 0 10px;
71 - color: #111827;
72 - font-size: 24px;
73 - line-height: 1.2;
74 - }
75 - p {
76 - margin: 0;
77 - color: #4b5563;
78 - font-size: 15px;
79 - line-height: 1.55;
80 - }
81 - .details {
82 - display: flex;
83 - gap: 12px;
84 - justify-content: space-between;
85 - margin-top: 18px;
86 - padding-top: 16px;
87 - border-top: 1px solid #e5e7eb;
88 - color: #374151;
89 - font-size: 13px;
90 - }
91 - .details strong {
92 - display: block;
93 - color: #111827;
94 - font-size: 14px;
95 - }
96 - a {
97 - display: inline-block;
98 - margin-top: 20px;
99 - padding: 9px 13px;
100 - border-radius: 6px;
101 - background: #111827;
102 - color: #ffffff;
103 - font-size: 14px;
104 - font-weight: 700;
105 - text-decoration: none;
106 - }
107 - @media (max-width: 480px) {
108 - body { padding: 14px; }
109 - section { padding: 18px; }
110 - .details { flex-direction: column; }
111 - }
112 - </style>
113 -</head>
114 -<body>
115 - <main>
116 - <img src="{{.PhotoURL}}" alt="Unlocked premium photo">
117 - <section>
118 - <p class="eyebrow">Payment complete</p>
119 - <h1>Thanks for the payment.</h1>
120 - <p>Your {{.Price}} x402 payment unlocked this photo.</p>
121 - <div class="details">
122 - <div>
123 - <span>Amount</span>
124 - <strong>{{.Price}}</strong>
125 - </div>
126 - <div>
127 - <span>Recipient</span>
128 - <strong>{{.RecipientAddress}}</strong>
129 - </div>
130 - </div>
131 - <a href="/">Back to demo</a>
132 - </section>
133 - </main>
134 -</body>
135 -`))
136 -
137 -type premiumPageData struct {
138 - Price string
139 - PhotoURL string
140 - RecipientAddress string
141 -}
142 -
143 -func newHandler(appIdentity types.Identity, metadata types.LeaseMetadata, x402Config *types.X402Config) (http.Handler, error) {
18 +func newHandler() http.Handler {
19 staticFS, _ := fs.Sub(staticFiles, "static")
20
21 mux := http.NewServeMux()
@@ -149,46 +24,7 @@ func newHandler(appIdentity types.Identity, metadata types.LeaseMetadata, x402Co
24 mux.Handle("/ws", websocket.Handler(handleWebSocket))
25 mux.HandleFunc("/api/test-cookies", handleCookies)
26
152 - if x402Config == nil || x402Config.Empty() {
153 - mux.HandleFunc(premiumPath, handlePaymentRequired)
154 - mux.HandleFunc(premiumPath+"/", handlePaymentRequired)
155 - return mux, nil
156 - }
157 -
158 - price := strings.TrimSpace(x402Config.Price)
159 - if price == "" {
160 - price = defaultPremiumCost
161 - }
162 -
163 - premiumHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
164 - w.Header().Set("Content-Type", "text/html; charset=utf-8")
165 - w.Header().Set("Cache-Control", "no-store")
166 - _ = premiumPage.Execute(w, premiumPageData{
167 - Price: price,
168 - PhotoURL: premiumPhotoURL,
169 - RecipientAddress: appIdentity.Address,
170 - })
171 - })
172 - routeX402 := *x402Config
173 - routeX402.Price = price
174 - if strings.TrimSpace(routeX402.Resource) == "" {
175 - routeX402.Resource = premiumPath
176 - }
177 - if strings.TrimSpace(routeX402.MimeType) == "" {
178 - routeX402.MimeType = "text/html"
179 - }
180 - protectedPremium, err := portalx402.NewHTTPRouteHandler(portalx402.HTTPRouteHandlerConfig{
181 - Prefix: premiumPath,
182 - Next: premiumHandler,
183 - X402: routeX402,
184 - TunnelIdentity: appIdentity,
185 - Metadata: metadata,
186 - })
187 - if err != nil {
188 - return nil, err
189 - }
190 - mux.Handle(premiumPath, protectedPremium)
191 - return mux, nil
27 + return mux
28 }
29
30 func handlePing(w http.ResponseWriter, _ *http.Request) {
@@ -241,17 +77,3 @@ func newUDPInfoHandler(exposure *sdk.Exposure) http.Handler {
77 })
78 return mux
79 }
244 -
245 -func handlePaymentRequired(w http.ResponseWriter, r *http.Request) {
246 - path := premiumPath
247 - if r != nil && r.URL != nil && r.URL.Path != "" {
248 - path = r.URL.Path
249 - }
250 - w.Header().Set("Content-Type", "application/json")
251 - w.WriteHeader(http.StatusPaymentRequired)
252 - _ = json.NewEncoder(w).Encode(map[string]any{
253 - "message": "payment required",
254 - "path": path,
255 - "hint": "start demo-app with --x402-facilitator-url and --x402-network to enable x402 settlement",
256 - })
257 -}
cmd/demo-app/main.go
+1 -37
@@ -9,7 +9,6 @@ import (
9 "io"
10 "net"
11 "os"
12 - "strings"
12
13 "github.com/rs/zerolog"
14 "github.com/rs/zerolog/log"
@@ -49,7 +48,6 @@ type demoConfig struct {
48 hide bool
49 thumbnail string
50 maxActiveRelays int
52 - x402 types.X402Config
51 }
52
53 // registerConnectivityFlags registers the relay, discovery, identity, and
@@ -75,12 +73,6 @@ func runTCPCommand(args []string) error {
73 utils.StringFlag(fs, &cfg.tags, "tags", "demo,connectivity,activity,cloud,sun,morning", "comma-separated lease tags")
74 utils.StringFlag(fs, &cfg.thumbnail, "thumbnail", "https://picsum.photos/640/360", "lease thumbnail")
75 utils.BoolFlag(fs, &cfg.hide, "hide", false, "hide this lease from listings")
78 - utils.StringFlag(fs, &cfg.x402.FacilitatorURL, "x402-facilitator-url", "https://gosunuts.xyz/x402", "x402 facilitator URL, such as https://relay.example.com:4017/x402")
79 - utils.StringFlag(fs, &cfg.x402.Network, "x402-network", "eip155:84532", "x402 payment network, such as eip155:8453")
80 - utils.StringFlag(fs, &cfg.x402.Price, "x402-price", "$0.01", "x402 price for the premium photo, such as $0.01")
81 - utils.StringFlag(fs, &cfg.x402.PayTo, "x402-pay-to", "", "x402 recipient address; empty uses the demo app identity address")
82 - fs.IntVar(&cfg.x402.MaxTimeoutSeconds, "x402-max-timeout", 0, "x402 max payment timeout seconds advertised to clients")
83 - fs.IntVar(&cfg.x402.PaymentTimeoutSecs, "x402-payment-timeout", 0, "x402 middleware verify/settle timeout seconds")
76
77 if err := utils.ParseFlagSet(fs, args, printTCPUsage); err != nil {
78 if errors.Is(err, flag.ErrHelp) {
@@ -97,21 +89,6 @@ func runTCPCommand(args []string) error {
89 return fmt.Errorf("invalid --name value: %w", err)
90 }
91 cfg.name = normalizedName
100 - if !cfg.x402.Empty() {
101 - switch {
102 - case strings.TrimSpace(cfg.x402.FacilitatorURL) == "":
103 - return errors.New("--x402-facilitator-url is required when x402 is enabled")
104 - case strings.TrimSpace(cfg.x402.Network) == "":
105 - return errors.New("--x402-network is required when x402 is enabled")
106 - case cfg.x402.MaxTimeoutSeconds < 0:
107 - return errors.New("--x402-max-timeout cannot be negative")
108 - case cfg.x402.PaymentTimeoutSecs < 0:
109 - return errors.New("--x402-payment-timeout cannot be negative")
110 - }
111 - if strings.TrimSpace(cfg.x402.MimeType) == "" {
112 - cfg.x402.MimeType = "text/html"
113 - }
114 - }
92
93 ctx, stop := utils.SignalContext()
94 defer stop()
@@ -179,19 +156,8 @@ func runTCPDemo(ctx context.Context, cfg demoConfig) error {
156 if err != nil {
157 return fmt.Errorf("invalid --addr value %q: %w", rawAddr, err)
158 }
182 - var x402Config *types.X402Config
183 - if !cfg.x402.Empty() {
184 - if strings.TrimSpace(cfg.x402.PayTo) == "" {
185 - cfg.x402.PayTo = types.X402PayToIdentity
186 - }
187 - x402Config = &cfg.x402
188 - }
189 - httpHandler, err := newHandler(exposure.Identity(), metadata, x402Config)
190 - if err != nil {
191 - return err
192 - }
159 defer exposure.Close()
194 - err = exposure.RunHTTP(ctx, httpHandler, cfg.addr)
160 + err = exposure.RunHTTP(ctx, newHandler(), cfg.addr)
161 if err != nil {
162 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
163 err = nil
@@ -289,7 +255,6 @@ func printRootUsage(w io.Writer) {
255 "demo-app --name my-app",
256 "demo-app tcp --addr 127.0.0.1:9000",
257 "demo-app udp",
292 - "demo-app --x402-facilitator-url https://relay.example.com:4017/x402 --x402-network eip155:8453",
258 },
259 )
260 }
@@ -304,7 +269,6 @@ func printTCPUsage(w io.Writer) {
269 "demo-app",
270 "demo-app --name my-app",
271 "demo-app tcp --addr 127.0.0.1:9000",
307 - "demo-app --x402-facilitator-url https://relay.example.com:4017/x402 --x402-network eip155:8453",
272 },
273 )
274 }
cmd/demo-app/static/index.html
-7
@@ -16,7 +16,6 @@
16
17 <div class="toolbar">
18 <button id="httpPingBtn">HTTP Ping</button>
19 - <button class="payment-button" data-premium-path="/api/premium">Unlock Photo $0.01</button>
19 <button id="testCookiesBtn">Test Cookies</button>
20 <button id="wsConnectBtn">WS Connect</button>
21 <button id="wsSendBtn" disabled>WS Send "hello"</button>
@@ -57,12 +56,6 @@
56 }
57 });
58
60 - for (const button of document.querySelectorAll('[data-premium-path]')) {
61 - button.addEventListener('click', () => {
62 - window.location.assign(button.dataset.premiumPath);
63 - });
64 - }
65 -
59 document.getElementById('testCookiesBtn').addEventListener('click', async () => {
60 statusEl.textContent = 'Cookies: Testing...';
61 try {
cmd/demo-app/static/style.css
-33
@@ -56,39 +56,6 @@ h1 {
56 border-color: #c3ccd6;
57 }
58
59 -.toolbar button.payment-button {
60 - display: inline-flex;
61 - align-items: center;
62 - gap: 7px;
63 - border-color: #0f766e;
64 - background: #0f766e;
65 - color: #ffffff;
66 - font-weight: 700;
67 - box-shadow: 0 5px 14px rgba(15, 118, 110, 0.24);
68 -}
69 -
70 -.toolbar button.payment-button::before {
71 - content: "USDC";
72 - padding: 1px 5px;
73 - border-radius: 999px;
74 - border: 1px solid rgba(255, 255, 255, 0.55);
75 - color: #ecfeff;
76 - font-size: 10px;
77 - font-weight: 800;
78 - line-height: 1.3;
79 -}
80 -
81 -.toolbar button.payment-button:hover {
82 - border-color: #115e59;
83 - background: #115e59;
84 - box-shadow: 0 7px 18px rgba(15, 118, 110, 0.30);
85 - transform: translateY(-1px);
86 -}
87 -
88 -.toolbar button.payment-button:active {
89 - transform: translateY(0);
90 -}
91 -
59 .canvas-wrapper {
60 border-radius: 4px;
61 border: 1px solid #e1e4e8;
cmd/demo-app/static/x402.html deleted
-63
@@ -1,63 +0,0 @@
1 -<!DOCTYPE html>
2 -<html lang="en">
3 -
4 -<head>
5 - <meta charset="UTF-8">
6 - <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
7 - <title>Portal Native x402 Demo</title>
8 - <link rel="stylesheet" href="style.css">
9 -</head>
10 -
11 -<body>
12 - <div class="container">
13 - <h1>Portal Native x402 Demo</h1>
14 -
15 - <div class="toolbar">
16 - <button id="freeBtn">Free API</button>
17 - <button class="payment-button" data-api-path="/api/premium">Unlock Photo $0.01</button>
18 - </div>
19 -
20 - <div class="canvas-wrapper">
21 - <pre id="log" class="log"></pre>
22 - </div>
23 -
24 - <div id="status" class="status">Idle</div>
25 - </div>
26 -
27 - <script>
28 - const statusEl = document.getElementById('status');
29 - const logEl = document.getElementById('log');
30 -
31 - function log(message) {
32 - const time = new Date().toISOString();
33 - logEl.textContent += `[${time}] ${message}\n`;
34 - logEl.scrollTop = logEl.scrollHeight;
35 - }
36 -
37 - async function callAPI(path) {
38 - statusEl.textContent = `${path}: Loading...`;
39 - try {
40 - const res = await fetch(path);
41 - const contentType = res.headers.get('content-type') || '';
42 - const body = contentType.includes('application/json')
43 - ? JSON.stringify(await res.json())
44 - : await res.text();
45 - statusEl.textContent = `${path}: ${res.status}`;
46 - log(`${path} -> ${res.status}`);
47 - log(body);
48 - } catch (err) {
49 - statusEl.textContent = `${path}: Error`;
50 - log(`${path} error: ${err}`);
51 - }
52 - }
53 -
54 - document.getElementById('freeBtn').addEventListener('click', () => callAPI('/api/ping'));
55 - for (const button of document.querySelectorAll('[data-api-path]')) {
56 - button.addEventListener('click', () => {
57 - window.location.assign(button.dataset.apiPath);
58 - });
59 - }
60 - </script>
61 -</body>
62 -
63 -</html>
cmd/payment-app/handler.go new
+259
@@ -0,0 +1,259 @@
1 +package main
2 +
3 +import (
4 + "embed"
5 + "html/template"
6 + "io/fs"
7 + "net/http"
8 + "strings"
9 +
10 + portalx402 "github.com/gosuda/portal-tunnel/v2/portal/x402"
11 + "github.com/gosuda/portal-tunnel/v2/types"
12 +)
13 +
14 +//go:embed static/index.html static/style.css
15 +var staticFiles embed.FS
16 +
17 +const paidPhotoPath = "/paid/photo"
18 +
19 +var (
20 + indexPage = template.Must(template.ParseFS(staticFiles, "static/index.html"))
21 + photoPage = template.Must(template.New("photo").Parse(`<!DOCTYPE html>
22 +<head>
23 + <meta charset="UTF-8">
24 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
25 + <title>{{.PageTitle}}</title>
26 + <meta name="description" content="{{.PageDescription}}">
27 + <meta property="og:type" content="website">
28 + <meta property="og:title" content="{{.PageTitle}}">
29 + <meta property="og:description" content="{{.PageDescription}}">
30 + <meta property="og:image" content="{{.OGImage}}">
31 + <meta property="og:url" content="{{.URL}}">
32 + <meta name="twitter:card" content="summary_large_image">
33 + <meta name="twitter:title" content="{{.PageTitle}}">
34 + <meta name="twitter:description" content="{{.PageDescription}}">
35 + <meta name="twitter:image" content="{{.OGImage}}">
36 + <style>
37 + * { box-sizing: border-box; }
38 + body {
39 + margin: 0;
40 + min-height: 100vh;
41 + display: grid;
42 + place-items: center;
43 + padding: 22px;
44 + background: #f7f8fb;
45 + color: #182033;
46 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
47 + }
48 + main {
49 + width: min(100%, 720px);
50 + overflow: hidden;
51 + border: 1px solid #d9e1ea;
52 + border-radius: 8px;
53 + background: #ffffff;
54 + box-shadow: 0 18px 42px rgba(24, 32, 51, 0.14);
55 + }
56 + img {
57 + width: 100%;
58 + aspect-ratio: 16 / 10;
59 + display: block;
60 + object-fit: cover;
61 + background: #dfe6ee;
62 + }
63 + section {
64 + display: grid;
65 + gap: 14px;
66 + padding: 22px;
67 + }
68 + .eyebrow {
69 + margin: 0;
70 + color: #0e7490;
71 + font-size: 12px;
72 + font-weight: 800;
73 + text-transform: uppercase;
74 + letter-spacing: 0;
75 + }
76 + h1 {
77 + margin: 0;
78 + color: #111827;
79 + font-size: 25px;
80 + line-height: 1.18;
81 + }
82 + p {
83 + margin: 0;
84 + color: #4b5565;
85 + font-size: 15px;
86 + line-height: 1.55;
87 + }
88 + dl {
89 + display: grid;
90 + grid-template-columns: repeat(3, 1fr);
91 + gap: 12px;
92 + margin: 4px 0 0;
93 + padding-top: 16px;
94 + border-top: 1px solid #e5eaf0;
95 + }
96 + div { min-width: 0; }
97 + dt {
98 + color: #667085;
99 + font-size: 12px;
100 + font-weight: 700;
101 + }
102 + dd {
103 + margin: 4px 0 0;
104 + overflow-wrap: anywhere;
105 + color: #111827;
106 + font-size: 13px;
107 + font-weight: 750;
108 + }
109 + @media (max-width: 640px) {
110 + body { padding: 12px; }
111 + section { padding: 18px; }
112 + dl { grid-template-columns: 1fr; }
113 + }
114 + </style>
115 +</head>
116 +<body>
117 + <main>
118 + <img src="{{.PhotoURL}}" alt="Unlocked protected image">
119 + <section>
120 + <p class="eyebrow">Payment complete</p>
121 + <h1>Image unlocked</h1>
122 + <p>The protected image is available after the {{.Price}} x402 settlement.</p>
123 + <dl>
124 + <div>
125 + <dt>Amount</dt>
126 + <dd>{{.Price}}</dd>
127 + </div>
128 + <div>
129 + <dt>Network</dt>
130 + <dd>{{.NetworkName}}</dd>
131 + </div>
132 + <div>
133 + <dt>Recipient</dt>
134 + <dd>{{.RecipientAddress}}</dd>
135 + </div>
136 + </dl>
137 + </section>
138 + </main>
139 +</body>
140 +`))
141 +)
142 +
143 +type paymentHandlerConfig struct {
144 + Identity types.Identity
145 + Metadata types.LeaseMetadata
146 + X402 types.X402Config
147 + PhotoURL string
148 +}
149 +
150 +type paymentPageData struct {
151 + PageTitle string
152 + PageDescription string
153 + URL string
154 + OGImage string
155 + ProtectedPath string
156 + Price string
157 + Network string
158 + NetworkName string
159 + PhotoURL string
160 + RecipientAddress string
161 +}
162 +
163 +func newHandler(cfg paymentHandlerConfig) (http.Handler, error) {
164 + staticFS, _ := fs.Sub(staticFiles, "static")
165 + pageData := newPaymentPageData(cfg)
166 +
167 + mux := http.NewServeMux()
168 + mux.Handle("/static/style.css", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
169 + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
170 + if r.URL.Path != "/" {
171 + http.NotFound(w, r)
172 + return
173 + }
174 + data := pageData
175 + data.URL = requestURL(r)
176 + w.Header().Set("Content-Type", "text/html; charset=utf-8")
177 + w.Header().Set("Cache-Control", "no-store")
178 + _ = indexPage.Execute(w, data)
179 + })
180 +
181 + photoHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
182 + data := pageData
183 + data.URL = requestURL(r)
184 + w.Header().Set("Content-Type", "text/html; charset=utf-8")
185 + w.Header().Set("Cache-Control", "no-store")
186 + _ = photoPage.Execute(w, data)
187 + })
188 + routeX402 := cfg.X402
189 + routeX402.Price = pageData.Price
190 + if strings.TrimSpace(routeX402.Resource) == "" {
191 + routeX402.Resource = paidPhotoPath
192 + }
193 + if strings.TrimSpace(routeX402.MimeType) == "" {
194 + routeX402.MimeType = "text/html"
195 + }
196 + protectedPhoto, err := portalx402.NewHTTPRouteHandler(portalx402.HTTPRouteHandlerConfig{
197 + Prefix: paidPhotoPath,
198 + Next: photoHandler,
199 + X402: routeX402,
200 + TunnelIdentity: cfg.Identity,
201 + Metadata: cfg.Metadata,
202 + })
203 + if err != nil {
204 + return nil, err
205 + }
206 + mux.Handle(paidPhotoPath, protectedPhoto)
207 + mux.Handle(paidPhotoPath+"/", protectedPhoto)
208 +
209 + return mux, nil
210 +}
211 +
212 +func newPaymentPageData(cfg paymentHandlerConfig) paymentPageData {
213 + network := strings.TrimSpace(cfg.X402.Network)
214 + networkName := portalx402.NetworkDisplayName(network)
215 + if networkName == "" {
216 + networkName = network
217 + }
218 + recipient := strings.TrimSpace(cfg.X402.PayTo)
219 + if recipient == "" || strings.EqualFold(recipient, types.X402PayToIdentity) {
220 + recipient = cfg.Identity.Address
221 + }
222 + price := strings.TrimSpace(cfg.X402.Price)
223 + description := strings.TrimSpace(cfg.Metadata.Description)
224 + if description == "" {
225 + description = "Settle " + price + " with x402 and reveal the protected image."
226 + }
227 + return paymentPageData{
228 + PageTitle: "Portal Native Payment",
229 + PageDescription: description,
230 + OGImage: strings.TrimSpace(cfg.PhotoURL),
231 + ProtectedPath: paidPhotoPath,
232 + Price: price,
233 + Network: network,
234 + NetworkName: networkName,
235 + PhotoURL: strings.TrimSpace(cfg.PhotoURL),
236 + RecipientAddress: recipient,
237 + }
238 +}
239 +
240 +func requestURL(r *http.Request) string {
241 + if r == nil || r.URL == nil {
242 + return ""
243 + }
244 + scheme := "http"
245 + if r.TLS != nil {
246 + scheme = "https"
247 + }
248 + if forwardedProto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" {
249 + scheme = strings.TrimSpace(strings.Split(forwardedProto, ",")[0])
250 + }
251 + host := r.Host
252 + if host == "" {
253 + host = r.Header.Get("Host")
254 + }
255 + if host == "" {
256 + return ""
257 + }
258 + return scheme + "://" + host + r.URL.Path
259 +}
cmd/payment-app/main.go new
+188
@@ -0,0 +1,188 @@
1 +package main
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "flag"
7 + "fmt"
8 + "io"
9 + "os"
10 + "strings"
11 +
12 + "github.com/rs/zerolog"
13 + "github.com/rs/zerolog/log"
14 +
15 + "github.com/gosuda/portal-tunnel/v2/sdk"
16 + "github.com/gosuda/portal-tunnel/v2/types"
17 + "github.com/gosuda/portal-tunnel/v2/utils"
18 +)
19 +
20 +const defaultPhotoURL = "https://image.s-h.day/generated/905a4835ad50.png"
21 +
22 +type paymentConfig struct {
23 + relayURLs string
24 + discovery bool
25 + banMITM bool
26 + identityPath string
27 + identityJSON string
28 + addr string
29 + name string
30 + desc string
31 + tags string
32 + owner string
33 + hide bool
34 + thumbnail string
35 + photoURL string
36 + maxActiveRelays int
37 + x402 types.X402Config
38 +}
39 +
40 +func main() {
41 + log.Logger = log.Output(zerolog.NewConsoleWriter())
42 + if err := run(os.Args[1:]); err != nil {
43 + log.Error().Err(err).Msg("payment app failed")
44 + os.Exit(1)
45 + }
46 +}
47 +
48 +func run(args []string) error {
49 + cfg := paymentConfig{}
50 + fs := utils.NewFlagSet("payment-app", printUsage)
51 +
52 + utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://gosunuts.xyz", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with bootstrap relays when discovery is enabled)", "RELAYS")
53 + utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", true, "include bootstrap relays and enable discovery", "DISCOVERY")
54 + utils.BoolFlagEnv(fs, &cfg.banMITM, "ban-mitm", false, "ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
55 + utils.StringFlagEnv(fs, &cfg.identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH")
56 + utils.StringFlagEnv(fs, &cfg.identityJSON, "identity-json", "", "identity json payload; overrides --identity-path contents and is persisted there when both are set", "IDENTITY_JSON")
57 + 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")
58 + utils.StringFlag(fs, &cfg.addr, "addr", "127.0.0.1:8093", "local payment app HTTP listen address (host:port or URL; disable if empty)")
59 + utils.StringFlag(fs, &cfg.name, "name", "payment-app", "public hostname prefix (single DNS label)")
60 + utils.StringFlag(fs, &cfg.desc, "description", "Portal native x402 payment app", "lease description")
61 + utils.StringFlag(fs, &cfg.tags, "tags", "payment,x402,image,photo", "comma-separated lease tags")
62 + utils.StringFlag(fs, &cfg.owner, "owner", "PortalApp Developer", "lease owner")
63 + utils.StringFlag(fs, &cfg.thumbnail, "thumbnail", defaultPhotoURL, "lease thumbnail")
64 + utils.StringFlag(fs, &cfg.photoURL, "photo-url", defaultPhotoURL, "image URL revealed after payment")
65 + utils.BoolFlag(fs, &cfg.hide, "hide", false, "hide this lease from listings")
66 + utils.StringFlag(fs, &cfg.x402.FacilitatorURL, "x402-facilitator-url", "https://gosunuts.xyz/x402", "x402 facilitator URL, such as https://relay.example.com:4017/x402")
67 + utils.StringFlag(fs, &cfg.x402.Network, "x402-network", "eip155:84532", "x402 payment network, such as eip155:8453")
68 + utils.StringFlag(fs, &cfg.x402.Price, "x402-price", "$0.001", "x402 price for the protected image, such as $0.01")
69 + utils.StringFlag(fs, &cfg.x402.PayTo, "x402-pay-to", "", "x402 recipient address; empty uses the payment app identity address")
70 + fs.IntVar(&cfg.x402.MaxTimeoutSeconds, "x402-max-timeout", 0, "x402 max payment timeout seconds advertised to clients")
71 + fs.IntVar(&cfg.x402.PaymentTimeoutSecs, "x402-payment-timeout", 0, "x402 middleware verify/settle timeout seconds")
72 +
73 + if err := utils.ParseFlagSet(fs, args, printUsage); err != nil {
74 + if errors.Is(err, flag.ErrHelp) {
75 + return nil
76 + }
77 + return err
78 + }
79 + if err := utils.RequireNoArgs(fs.Args(), "payment-app"); err != nil {
80 + printUsage(os.Stderr)
81 + return err
82 + }
83 + normalizedName, err := utils.NormalizeDNSLabel(cfg.name)
84 + if err != nil {
85 + return fmt.Errorf("invalid --name value: %w", err)
86 + }
87 + cfg.name = normalizedName
88 + if err := validatePaymentConfig(cfg); err != nil {
89 + return err
90 + }
91 +
92 + ctx, stop := utils.SignalContext()
93 + defer stop()
94 +
95 + return runPaymentApp(ctx, cfg)
96 +}
97 +
98 +func validatePaymentConfig(cfg paymentConfig) error {
99 + switch {
100 + case strings.TrimSpace(cfg.x402.FacilitatorURL) == "":
101 + return errors.New("--x402-facilitator-url is required")
102 + case strings.TrimSpace(cfg.x402.Network) == "":
103 + return errors.New("--x402-network is required")
104 + case strings.TrimSpace(cfg.x402.Price) == "":
105 + return errors.New("--x402-price is required")
106 + case strings.TrimSpace(cfg.photoURL) == "":
107 + return errors.New("--photo-url is required")
108 + case cfg.x402.MaxTimeoutSeconds < 0:
109 + return errors.New("--x402-max-timeout cannot be negative")
110 + case cfg.x402.PaymentTimeoutSecs < 0:
111 + return errors.New("--x402-payment-timeout cannot be negative")
112 + default:
113 + return nil
114 + }
115 +}
116 +
117 +func runPaymentApp(ctx context.Context, cfg paymentConfig) error {
118 + metadata := types.LeaseMetadata{
119 + Description: cfg.desc,
120 + Tags: utils.SplitCSV(cfg.tags),
121 + Owner: cfg.owner,
122 + Thumbnail: cfg.thumbnail,
123 + Hide: cfg.hide,
124 + }
125 + exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
126 + RelayURLs: utils.SplitCSV(cfg.relayURLs),
127 + Discovery: cfg.discovery,
128 + Identity: types.Identity{Name: cfg.name},
129 + IdentityPath: cfg.identityPath,
130 + IdentityJSON: cfg.identityJSON,
131 + BanMITM: cfg.banMITM,
132 + MaxActiveRelays: cfg.maxActiveRelays,
133 + Metadata: metadata,
134 + })
135 + if err != nil {
136 + return fmt.Errorf("exposure listen error: %w", err)
137 + }
138 + defer exposure.Close()
139 +
140 + rawAddr := cfg.addr
141 + cfg.addr, err = utils.NormalizeTargetAddr(cfg.addr)
142 + if err != nil {
143 + return fmt.Errorf("invalid --addr value %q: %w", rawAddr, err)
144 + }
145 + if strings.TrimSpace(cfg.x402.PayTo) == "" {
146 + cfg.x402.PayTo = types.X402PayToIdentity
147 + }
148 + if strings.TrimSpace(cfg.x402.MimeType) == "" {
149 + cfg.x402.MimeType = "text/html"
150 + }
151 +
152 + handler, err := newHandler(paymentHandlerConfig{
153 + Identity: exposure.Identity(),
154 + Metadata: metadata,
155 + X402: cfg.x402,
156 + PhotoURL: cfg.photoURL,
157 + })
158 + if err != nil {
159 + return err
160 + }
161 +
162 + err = exposure.RunHTTP(ctx, handler, cfg.addr)
163 + if err != nil {
164 + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
165 + err = nil
166 + }
167 + return err
168 + }
169 +
170 + if ctx.Err() != nil {
171 + log.Info().Msg("payment app shutting down")
172 + }
173 + log.Info().Msg("payment app shutdown complete")
174 + return nil
175 +}
176 +
177 +func printUsage(w io.Writer) {
178 + utils.WriteCommandUsage(w,
179 + []string{
180 + "payment-app [flags]",
181 + },
182 + []string{
183 + "payment-app",
184 + "payment-app --name paid-photo",
185 + "payment-app --x402-facilitator-url https://relay.example.com:4017/x402 --x402-network eip155:8453 --x402-price \"$0.01\"",
186 + },
187 + )
188 +}
cmd/payment-app/static/index.html new
+109
@@ -0,0 +1,109 @@
1 +<!DOCTYPE html>
2 +<html lang="en">
3 +
4 +<head>
5 + <meta charset="UTF-8">
6 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
7 + <title>{{.PageTitle}}</title>
8 + <meta name="description" content="{{.PageDescription}}">
9 + <meta property="og:type" content="website">
10 + <meta property="og:title" content="{{.PageTitle}}">
11 + <meta property="og:description" content="{{.PageDescription}}">
12 + <meta property="og:image" content="{{.OGImage}}">
13 + <meta property="og:url" content="{{.URL}}">
14 + <meta name="twitter:card" content="summary_large_image">
15 + <meta name="twitter:title" content="{{.PageTitle}}">
16 + <meta name="twitter:description" content="{{.PageDescription}}">
17 + <meta name="twitter:image" content="{{.OGImage}}">
18 + <link rel="stylesheet" href="/static/style.css">
19 +</head>
20 +
21 +<body>
22 + <main class="shell">
23 + <section class="app-surface" aria-label="Payment app">
24 + <div class="summary">
25 + <div class="kicker">
26 + <span>Native x402</span>
27 + <span>{{.NetworkName}}</span>
28 + </div>
29 + <h1>Paid image delivery</h1>
30 + <p class="lede">Settle {{.Price}} and reveal the protected image in the viewer.</p>
31 +
32 + <dl class="facts">
33 + <div>
34 + <dt>Amount</dt>
35 + <dd>{{.Price}}</dd>
36 + </div>
37 + <div>
38 + <dt>Network</dt>
39 + <dd>{{.Network}}</dd>
40 + </div>
41 + <div>
42 + <dt>Recipient</dt>
43 + <dd>{{.RecipientAddress}}</dd>
44 + </div>
45 + </dl>
46 +
47 + <div class="actions">
48 + <button id="unlockButton" class="primary-button" type="button" data-protected-path="{{.ProtectedPath}}">
49 + Open checkout
50 + </button>
51 + <span id="status" class="status">Ready</span>
52 + </div>
53 + </div>
54 +
55 + <div id="viewer" class="preview" aria-label="Protected image viewer">
56 + <div class="preview-topbar">
57 + <div class="window-controls" aria-hidden="true">
58 + <span></span>
59 + <span></span>
60 + <span></span>
61 + </div>
62 + <span id="viewerTitle" class="viewer-title">Protected image</span>
63 + <button id="resetViewer" class="viewer-reset" type="button" hidden>Reset</button>
64 + </div>
65 + <div id="lockedPreview" class="preview-body">
66 + <div class="lock-mark">402</div>
67 + <div>
68 + <p class="preview-label">Protected image</p>
69 + <p class="preview-title">Checkout opens here</p>
70 + </div>
71 + </div>
72 + <iframe id="paymentFrame" class="payment-frame" title="x402 payment checkout" allow="clipboard-read; clipboard-write; payment; publickey-credentials-get" hidden></iframe>
73 + </div>
74 + </section>
75 + </main>
76 +
77 + <script>
78 + const unlockButton = document.getElementById('unlockButton');
79 + const viewer = document.getElementById('viewer');
80 + const lockedPreview = document.getElementById('lockedPreview');
81 + const paymentFrame = document.getElementById('paymentFrame');
82 + const resetViewer = document.getElementById('resetViewer');
83 + const viewerTitle = document.getElementById('viewerTitle');
84 + const statusEl = document.getElementById('status');
85 +
86 + unlockButton.addEventListener('click', () => {
87 + viewer.classList.add('is-active');
88 + lockedPreview.hidden = true;
89 + paymentFrame.hidden = false;
90 + resetViewer.hidden = false;
91 + viewerTitle.textContent = 'x402 checkout';
92 + statusEl.textContent = 'Checkout open';
93 + if (!paymentFrame.src) {
94 + paymentFrame.src = unlockButton.dataset.protectedPath;
95 + }
96 + });
97 +
98 + resetViewer.addEventListener('click', () => {
99 + viewer.classList.remove('is-active');
100 + lockedPreview.hidden = false;
101 + paymentFrame.hidden = true;
102 + resetViewer.hidden = true;
103 + viewerTitle.textContent = 'Protected image';
104 + statusEl.textContent = paymentFrame.src ? 'Checkout paused' : 'Ready';
105 + });
106 + </script>
107 +</body>
108 +
109 +</html>
cmd/payment-app/static/style.css new
+350
@@ -0,0 +1,350 @@
1 +* {
2 + box-sizing: border-box;
3 +}
4 +
5 +body {
6 + margin: 0;
7 + min-height: 100vh;
8 + background: #f4f7f8;
9 + color: #182033;
10 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
11 +}
12 +
13 +button {
14 + font: inherit;
15 +}
16 +
17 +.shell {
18 + width: min(1120px, calc(100% - 32px));
19 + margin: 0 auto;
20 + padding: 42px 0;
21 +}
22 +
23 +.app-surface {
24 + display: grid;
25 + grid-template-columns: minmax(0, 0.88fr) minmax(360px, 1.12fr);
26 + gap: 24px;
27 + align-items: stretch;
28 +}
29 +
30 +.summary,
31 +.preview {
32 + border: 1px solid #d8e0e7;
33 + border-radius: 8px;
34 + background: #ffffff;
35 + box-shadow: 0 18px 38px rgba(20, 32, 46, 0.10);
36 +}
37 +
38 +.summary {
39 + display: flex;
40 + min-height: 500px;
41 + flex-direction: column;
42 + justify-content: space-between;
43 + padding: 28px;
44 +}
45 +
46 +.kicker {
47 + display: flex;
48 + flex-wrap: wrap;
49 + gap: 8px;
50 + margin-bottom: 24px;
51 +}
52 +
53 +.kicker span {
54 + border: 1px solid #c7d2dc;
55 + border-radius: 999px;
56 + padding: 5px 9px;
57 + color: #334155;
58 + font-size: 12px;
59 + font-weight: 750;
60 +}
61 +
62 +h1,
63 +h2,
64 +p,
65 +dl {
66 + margin: 0;
67 +}
68 +
69 +h1 {
70 + max-width: 10ch;
71 + color: #121826;
72 + font-size: 48px;
73 + line-height: 1.02;
74 + font-weight: 800;
75 +}
76 +
77 +.lede {
78 + max-width: 34ch;
79 + margin-top: 16px;
80 + color: #536171;
81 + font-size: 16px;
82 + line-height: 1.55;
83 +}
84 +
85 +.facts {
86 + display: grid;
87 + gap: 12px;
88 + margin-top: 28px;
89 +}
90 +
91 +.facts div {
92 + min-width: 0;
93 + border-top: 1px solid #e5ebf0;
94 + padding-top: 12px;
95 +}
96 +
97 +dt {
98 + color: #687586;
99 + font-size: 12px;
100 + font-weight: 750;
101 +}
102 +
103 +dd {
104 + margin: 4px 0 0;
105 + overflow-wrap: anywhere;
106 + color: #111827;
107 + font-size: 14px;
108 + font-weight: 760;
109 +}
110 +
111 +.actions {
112 + display: flex;
113 + flex-wrap: wrap;
114 + gap: 12px;
115 + align-items: center;
116 + margin-top: 30px;
117 +}
118 +
119 +.primary-button {
120 + min-height: 42px;
121 + border-radius: 6px;
122 + border: 1px solid transparent;
123 + padding: 0 16px;
124 + cursor: pointer;
125 + font-size: 14px;
126 + font-weight: 780;
127 + transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
128 +}
129 +
130 +.primary-button {
131 + background: #0e7490;
132 + color: #ffffff;
133 + box-shadow: 0 10px 22px rgba(14, 116, 144, 0.24);
134 +}
135 +
136 +.primary-button:hover {
137 + background: #155e75;
138 + transform: translateY(-1px);
139 +}
140 +
141 +.status {
142 + color: #667085;
143 + font-size: 13px;
144 + font-weight: 700;
145 +}
146 +
147 +.preview {
148 + position: relative;
149 + min-height: 500px;
150 + overflow: hidden;
151 + background:
152 + linear-gradient(140deg, rgba(14, 116, 144, 0.14), transparent 34%),
153 + linear-gradient(42deg, rgba(180, 83, 9, 0.17), transparent 42%),
154 + #edf2f6;
155 +}
156 +
157 +.preview.is-active {
158 + min-height: 680px;
159 + background: #ffffff;
160 +}
161 +
162 +.preview-topbar {
163 + display: flex;
164 + justify-content: space-between;
165 + gap: 12px;
166 + align-items: center;
167 + height: 42px;
168 + padding: 0 16px;
169 + border-bottom: 1px solid rgba(24, 32, 51, 0.10);
170 + background: rgba(255, 255, 255, 0.72);
171 +}
172 +
173 +.window-controls {
174 + display: flex;
175 + gap: 7px;
176 + align-items: center;
177 + min-width: 44px;
178 +}
179 +
180 +.window-controls span {
181 + width: 9px;
182 + height: 9px;
183 + border-radius: 999px;
184 + background: #94a3b8;
185 +}
186 +
187 +.window-controls span:nth-child(1) {
188 + background: #b45309;
189 +}
190 +
191 +.window-controls span:nth-child(2) {
192 + background: #0e7490;
193 +}
194 +
195 +.window-controls span:nth-child(3) {
196 + background: #475569;
197 +}
198 +
199 +.viewer-title {
200 + min-width: 0;
201 + overflow: hidden;
202 + color: #334155;
203 + font-size: 12px;
204 + font-weight: 800;
205 + text-overflow: ellipsis;
206 + white-space: nowrap;
207 +}
208 +
209 +.viewer-reset {
210 + min-width: 44px;
211 + border: 0;
212 + background: transparent;
213 + color: #0e7490;
214 + cursor: pointer;
215 + font-size: 12px;
216 + font-weight: 800;
217 +}
218 +
219 +.viewer-reset[hidden] {
220 + display: block;
221 + visibility: hidden;
222 +}
223 +
224 +.preview-body {
225 + position: absolute;
226 + inset: 42px 0 0;
227 + display: grid;
228 + place-items: center;
229 + gap: 18px;
230 + align-content: center;
231 + padding: 28px;
232 + text-align: center;
233 +}
234 +
235 +.preview-body::before {
236 + position: absolute;
237 + inset: 36px;
238 + content: "";
239 + border: 1px dashed rgba(71, 85, 105, 0.28);
240 + border-radius: 8px;
241 + background:
242 + repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.34) 0 10px, transparent 10px 20px),
243 + rgba(255, 255, 255, 0.32);
244 +}
245 +
246 +.lock-mark {
247 + position: relative;
248 + z-index: 1;
249 + display: grid;
250 + width: 92px;
251 + height: 92px;
252 + place-items: center;
253 + border-radius: 8px;
254 + background: #121826;
255 + color: #f8fafc;
256 + font-size: 30px;
257 + font-weight: 850;
258 +}
259 +
260 +.preview-body > div:last-child {
261 + position: relative;
262 + z-index: 1;
263 +}
264 +
265 +.preview-label {
266 + color: #0e7490;
267 + font-size: 13px;
268 + font-weight: 800;
269 + text-transform: uppercase;
270 +}
271 +
272 +.preview-title {
273 + margin-top: 8px;
274 + color: #182033;
275 + font-size: 22px;
276 + font-weight: 800;
277 +}
278 +
279 +.preview-body[hidden],
280 +.payment-frame[hidden] {
281 + display: none;
282 +}
283 +
284 +.payment-frame {
285 + position: absolute;
286 + inset: 42px 0 0;
287 + display: block;
288 + width: 100%;
289 + height: calc(100% - 42px);
290 + border: 0;
291 + background: #f8fafc;
292 +}
293 +
294 +@media (max-width: 860px) {
295 + .shell {
296 + width: min(100% - 20px, 640px);
297 + padding: 18px 0;
298 + }
299 +
300 + .app-surface {
301 + grid-template-columns: 1fr;
302 + }
303 +
304 + .summary,
305 + .preview {
306 + min-height: auto;
307 + }
308 +
309 + h1 {
310 + max-width: none;
311 + font-size: 36px;
312 + }
313 +
314 + .preview {
315 + height: 360px;
316 + }
317 +
318 + .preview.is-active {
319 + height: 660px;
320 + min-height: 660px;
321 + }
322 +}
323 +
324 +@media (max-width: 520px) {
325 + .summary {
326 + padding: 20px;
327 + }
328 +
329 + .actions {
330 + flex-direction: column;
331 + align-items: stretch;
332 + }
333 +
334 + .primary-button {
335 + width: 100%;
336 + }
337 +
338 + .preview {
339 + height: 310px;
340 + }
341 +
342 + .preview.is-active {
343 + height: 620px;
344 + min-height: 620px;
345 + }
346 +
347 + .preview-body::before {
348 + inset: 18px;
349 + }
350 +}
cmd/portal-tunnel/README.md
+4 -3
@@ -150,11 +150,12 @@ protected, err := portalx402.NewHTTPRouteHandler(portalx402.HTTPRouteHandlerConf
150 })
151 ```
152
153 -`cmd/demo-app` includes this native x402 pattern. Run it with:
153 +`cmd/payment-app` includes this native x402 pattern. Run it with:
154
155 ```text
156 -demo-app --x402-facilitator-url https://portal.example.com:4017/x402 \
157 - --x402-network eip155:8453
156 +payment-app --x402-facilitator-url https://portal.example.com:4017/x402 \
157 + --x402-network eip155:8453 \
158 + --x402-price "$0.01"
159 ```
160
161 Use dedicated raw TCP mode for non-HTTP services that need a public TCP port:
docs/src/routes/cli-reference/+page.md
+2 -2
@@ -216,10 +216,10 @@ protected, err := portalx402.NewHTTPRouteHandler(portalx402.HTTPRouteHandlerConf
216 })
217 ```
218
219 -The demo app exposes the same pattern:
219 +The payment app exposes the same pattern:
220
221 ```bash
222 -go run ./cmd/demo-app --x402-facilitator-url https://portal.example.com/x402 --x402-network eip155:8453
222 +go run ./cmd/payment-app --x402-facilitator-url https://portal.example.com/x402 --x402-network eip155:8453 --x402-price "$0.01"
223 ```
224
225 Expose a Minecraft server:
docs/src/routes/self-hosting/+page.md
+4 -3
@@ -161,14 +161,15 @@ portal expose 3000 \
161 ```
162
163 Native Go apps can use the same relay facilitator without putting payment
164 -policy in the tunnel config. The demo app includes per-path pricing:
164 +policy in the tunnel config. The payment app includes a native paid image route:
165
166 ```bash
167 -go run ./cmd/demo-app \
167 +go run ./cmd/payment-app \
168 --relays https://relay.example.com:4017 \
169 --discovery=false \
170 --x402-facilitator-url https://relay.example.com:4017/x402 \
171 - --x402-network eip155:8453
171 + --x402-network eip155:8453 \
172 + --x402-price "$0.01"
173 ```
174
175 ## Troubleshooting
frontend/src/components/ServerCard.tsx
+2 -2
@@ -330,8 +330,8 @@ export function ServerCard({
330 )}
331
332 {tags && tags.length > 0 && (
333 - <div className="w-full overflow-x-auto mt-1">
334 - <div className="flex gap-1.5 min-w-max">
333 + <div className="mt-1 w-full overflow-x-auto overflow-y-hidden overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
334 + <div className="flex min-w-max gap-1.5">
335 {tags.map((tag, index) => (
336 <span
337 key={index}