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}