feat: update x402 payment handling and UI for premium content
Kim committed
May 27, 2026 at 18:54 UTC
102a25f74d4c6bf443eb5095654779ca50b22cf6
14 files changed
+233
-129
cmd/demo-app/handler.go
+147
-56
@@ -1,10 +1,9 @@
1
package main
2
3
import (
4
- "context"
4
"embed"
5
"encoding/json"
7
- "fmt"
6
+ "html/template"
7
"io/fs"
8
"net/http"
9
"strings"
@@ -20,16 +19,125 @@ import (
19
//go:embed static
20
var staticFiles embed.FS
21
23
-type premiumContent struct {
24
- Title string
25
- Price string
26
-}
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
28
-var premiumCatalog = map[string]premiumContent{
29
- "/api/premium": {Title: "Premium overview"},
30
- "/api/premium/basic": {Title: "Basic premium payload", Price: "$0.001"},
31
- "/api/premium/report": {Title: "Market report", Price: "$0.010"},
32
- "/api/premium/dataset": {Title: "Dataset export", Price: "$0.050"},
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) {
@@ -41,62 +149,45 @@ func newHandler(appIdentity types.Identity, metadata types.LeaseMetadata, x402Co
149
mux.Handle("/ws", websocket.Handler(handleWebSocket))
150
mux.HandleFunc("/api/test-cookies", handleCookies)
151
44
- premiumHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45
- item, ok := premiumCatalog[r.URL.Path]
46
- if !ok {
47
- http.NotFound(w, r)
48
- return
49
- }
50
- price := item.Price
51
- if price == "" && x402Config != nil {
52
- price = strings.TrimSpace(x402Config.Price)
53
- }
54
- if price == "" {
55
- price = "$0.001"
56
- }
57
- w.Header().Set("Content-Type", "application/json")
58
- _ = json.NewEncoder(w).Encode(map[string]any{
59
- "content": item.Title,
60
- "message": "premium data unlocked",
61
- "paid": true,
62
- "price": price,
63
- "recipient_address": appIdentity.Address,
64
- "time": time.Now().UTC().Format(time.RFC3339),
65
- })
66
- })
152
if x402Config == nil || x402Config.Empty() {
68
- mux.HandleFunc("/api/premium", handlePaymentRequired)
69
- mux.HandleFunc("/api/premium/", handlePaymentRequired)
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{
74
- Prefix: "/api/premium",
181
+ Prefix: premiumPath,
182
Next: premiumHandler,
76
- X402: *x402Config,
183
+ X402: routeX402,
184
TunnelIdentity: appIdentity,
185
Metadata: metadata,
79
- PriceResolver: func(_ context.Context, req portalx402.HTTPRequestContext) (string, error) {
80
- item, ok := premiumCatalog[req.Path]
81
- if !ok {
82
- return "", fmt.Errorf("unknown premium content path %q", req.Path)
83
- }
84
- if item.Price != "" {
85
- return item.Price, nil
86
- }
87
- price := strings.TrimSpace(x402Config.Price)
88
- if price == "" {
89
- price = "$0.001"
90
- }
91
- return price, nil
92
- },
186
})
187
if err != nil {
188
return nil, err
189
}
97
- for path := range premiumCatalog {
98
- mux.Handle(path, protectedPremium)
99
- }
190
+ mux.Handle(premiumPath, protectedPremium)
191
return mux, nil
192
}
193
@@ -152,7 +243,7 @@ func newUDPInfoHandler(exposure *sdk.Exposure) http.Handler {
243
}
244
245
func handlePaymentRequired(w http.ResponseWriter, r *http.Request) {
155
- path := "/api/premium"
246
+ path := premiumPath
247
if r != nil && r.URL != nil && r.URL.Path != "" {
248
path = r.URL.Path
249
}
cmd/demo-app/main.go
+4
-10
@@ -75,13 +75,10 @@ func runTCPCommand(args []string) error {
75
utils.StringFlag(fs, &cfg.tags, "tags", "demo,connectivity,activity,cloud,sun,morning", "comma-separated lease tags")
76
utils.StringFlag(fs, &cfg.thumbnail, "thumbnail", "https://picsum.photos/640/360", "lease thumbnail")
77
utils.BoolFlag(fs, &cfg.hide, "hide", false, "hide this lease from listings")
78
- utils.StringFlag(fs, &cfg.x402.FacilitatorURL, "x402-facilitator-url", "", "x402 facilitator URL, such as https://relay.example.com:4017/x402")
79
- utils.StringFlag(fs, &cfg.x402.Network, "x402-network", "", "x402 payment network, such as eip155:8453")
80
- utils.StringFlag(fs, &cfg.x402.Price, "x402-price", "", "fallback x402 price for premium content, such as $0.001")
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
- utils.StringFlag(fs, &cfg.x402.Resource, "x402-resource", "", "x402 protected resource path; defaults to /api/premium")
83
- utils.StringFlag(fs, &cfg.x402.MimeType, "x402-mime-type", "", "x402 protected resource MIME type; defaults to application/json")
84
- utils.BoolFlag(fs, &cfg.x402.Testnet, "x402-testnet", false, "render the x402 paywall in testnet mode")
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")
84
@@ -111,11 +108,8 @@ func runTCPCommand(args []string) error {
108
case cfg.x402.PaymentTimeoutSecs < 0:
109
return errors.New("--x402-payment-timeout cannot be negative")
110
}
114
- if strings.TrimSpace(cfg.x402.Resource) == "" {
115
- cfg.x402.Resource = "/api/premium"
116
- }
111
if strings.TrimSpace(cfg.x402.MimeType) == "" {
118
- cfg.x402.MimeType = "application/json"
112
+ cfg.x402.MimeType = "text/html"
113
}
114
}
115
cmd/demo-app/static/index.html
+4
-22
@@ -16,10 +16,7 @@
16
17
<div class="toolbar">
18
<button id="httpPingBtn">HTTP Ping</button>
19
- <button data-premium-path="/api/premium">Premium Overview</button>
20
- <button data-premium-path="/api/premium/basic">Basic $0.001</button>
21
- <button data-premium-path="/api/premium/report">Report $0.010</button>
22
- <button data-premium-path="/api/premium/dataset">Dataset $0.050</button>
19
+ <button class="payment-button" data-premium-path="/api/premium">Unlock Photo $0.01</button>
20
<button id="testCookiesBtn">Test Cookies</button>
21
<button id="wsConnectBtn">WS Connect</button>
22
<button id="wsSendBtn" disabled>WS Send "hello"</button>
@@ -60,25 +57,10 @@
57
}
58
});
59
63
- async function callPremium(path) {
64
- statusEl.textContent = `${path}: Loading...`;
65
- try {
66
- const res = await fetch(path);
67
- const contentType = res.headers.get('content-type') || '';
68
- const body = contentType.includes('application/json')
69
- ? JSON.stringify(await res.json())
70
- : await res.text();
71
- statusEl.textContent = `${path}: ${res.status}`;
72
- log(`HTTP ${path} -> ${res.status}`);
73
- log(body);
74
- } catch (err) {
75
- statusEl.textContent = `${path}: Error`;
76
- log(`${path} error: ${err}`);
77
- }
78
- }
79
-
60
for (const button of document.querySelectorAll('[data-premium-path]')) {
81
- button.addEventListener('click', () => callPremium(button.dataset.premiumPath));
61
+ button.addEventListener('click', () => {
62
+ window.location.assign(button.dataset.premiumPath);
63
+ });
64
}
65
66
document.getElementById('testCookiesBtn').addEventListener('click', async () => {
cmd/demo-app/static/style.css
+36
-2
@@ -47,7 +47,8 @@ h1 {
47
border: 1px solid #d0d7de;
48
background: #ffffff;
49
cursor: pointer;
50
- transition: background 0.15s ease, border-color 0.15s ease;
50
+ transition: background 0.15s ease, border-color 0.15s ease,
51
+ box-shadow 0.15s ease, transform 0.15s ease;
52
}
53
54
.toolbar button:hover {
@@ -55,6 +56,39 @@ 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
+
92
.canvas-wrapper {
93
border-radius: 4px;
94
border: 1px solid #e1e4e8;
@@ -82,4 +116,4 @@ h1 {
116
font-size: 13px;
117
color: #4b5563;
118
text-align: right;
85
-}
\ No newline at end of file
119
+}
cmd/demo-app/static/x402.html
+4
-5
@@ -14,10 +14,7 @@
14
15
<div class="toolbar">
16
<button id="freeBtn">Free API</button>
17
- <button data-api-path="/api/premium">Overview</button>
18
- <button data-api-path="/api/premium/basic">Basic $0.001</button>
19
- <button data-api-path="/api/premium/report">Report $0.010</button>
20
- <button data-api-path="/api/premium/dataset">Dataset $0.050</button>
17
+ <button class="payment-button" data-api-path="/api/premium">Unlock Photo $0.01</button>
18
</div>
19
20
<div class="canvas-wrapper">
@@ -56,7 +53,9 @@
53
54
document.getElementById('freeBtn').addEventListener('click', () => callAPI('/api/ping'));
55
for (const button of document.querySelectorAll('[data-api-path]')) {
59
- button.addEventListener('click', () => callAPI(button.dataset.apiPath));
56
+ button.addEventListener('click', () => {
57
+ window.location.assign(button.dataset.apiPath);
58
+ });
59
}
60
</script>
61
</body>
cmd/portal-tunnel/README.md
+3
-4
@@ -89,8 +89,8 @@ internally as `--http-route /=<target>`. `--x402-pay-to` defaults to the tunnel
89
identity address; set it explicitly when payments should be received by another
90
wallet. The x402 paywall uses tunnel metadata: `--name` for the app name,
91
`--description` for the resource description, and `--thumbnail` for the app
92
-logo. `--x402-resource` defaults to the matched HTTP route prefix and controls
93
-the resource path advertised in the x402 payment requirement. The tunnel does
92
+logo. Empty `--x402-resource` uses the requested URL in the x402 payment
93
+requirement. Set it only when a stable resource URL should be advertised. The tunnel does
94
not infer a relay facilitator URL; set `--x402-facilitator-url` explicitly, or
95
use relay/frontend tooling that writes the desired facilitator URL into the
96
tunnel config. x402 is not available in raw TCP or UDP modes.
@@ -262,9 +262,8 @@ Common flags:
262
--x402-pay-to x402 recipient address; defaults to the tunnel identity address
263
--x402-facilitator-url
264
x402 facilitator URL
265
---x402-resource x402 protected resource/root path; defaults to the HTTP route prefix
265
+--x402-resource x402 protected resource URL; empty uses the requested URL
266
--x402-mime-type x402 protected resource MIME type
267
---x402-testnet Render the x402 paywall in testnet mode
267
--x402-max-timeout x402 max payment timeout seconds advertised to clients
268
--x402-payment-timeout
269
x402 middleware verify/settle timeout seconds
cmd/portal-tunnel/agent/config.go
-3
@@ -227,9 +227,6 @@ func x402ConfigDocumentMap(cfg types.X402Config) map[string]any {
227
addStringDocumentField(out, "facilitator_url", cfg.FacilitatorURL)
228
addStringDocumentField(out, "resource", cfg.Resource)
229
addStringDocumentField(out, "mime_type", cfg.MimeType)
230
- if cfg.Testnet {
231
- out["testnet"] = cfg.Testnet
232
- }
230
if cfg.MaxTimeoutSeconds != 0 {
231
out["max_timeout_seconds"] = cfg.MaxTimeoutSeconds
232
}
cmd/portal-tunnel/main.go
+1
-4
@@ -77,7 +77,6 @@ type exposeX402Flags struct {
77
facilitator string
78
resource string
79
mimeType string
80
- testnet bool
80
maxTimeout int
81
paymentTimeout int
82
}
@@ -215,9 +214,8 @@ func (f *exposeX402Flags) bind(fs *flag.FlagSet) {
214
utils.StringFlag(fs, &f.price, "x402-price", "", "x402 route price, such as $0.001")
215
utils.StringFlag(fs, &f.payTo, "x402-pay-to", "", "x402 recipient address; empty uses the tunnel identity address")
216
utils.StringFlag(fs, &f.facilitator, "x402-facilitator-url", "", "x402 facilitator URL")
218
- utils.StringFlag(fs, &f.resource, "x402-resource", "", "x402 protected resource/root path; defaults to the HTTP route prefix")
217
+ utils.StringFlag(fs, &f.resource, "x402-resource", "", "x402 protected resource URL; empty uses the requested URL")
218
utils.StringFlag(fs, &f.mimeType, "x402-mime-type", "", "x402 protected resource MIME type")
220
- utils.BoolFlag(fs, &f.testnet, "x402-testnet", false, "Render the x402 paywall in testnet mode")
219
fs.IntVar(&f.maxTimeout, "x402-max-timeout", 0, "x402 max payment timeout seconds advertised to clients")
220
fs.IntVar(&f.paymentTimeout, "x402-payment-timeout", 0, "x402 middleware verify/settle timeout seconds")
221
}
@@ -230,7 +228,6 @@ func (f exposeX402Flags) config() (*types.X402Config, error) {
228
FacilitatorURL: f.facilitator,
229
Resource: f.resource,
230
MimeType: f.mimeType,
233
- Testnet: f.testnet,
231
MaxTimeoutSeconds: f.maxTimeout,
232
PaymentTimeoutSecs: f.paymentTimeout,
233
}
cmd/relay-server/main.go
+1
-1
@@ -99,7 +99,7 @@ func runServeCommand(args []string) error {
99
utils.StringFlagEnv(fs, &cfg.PProfAddr, "pprof-addr", portal.DefaultPProfListenAddr, "pprof diagnostics listen address when enabled", "PPROF_ADDR")
100
utils.BoolFlagEnv(fs, &cfg.X402Enabled, "x402-facilitator-enabled", false, "enable relay-local x402 facilitator endpoints under /x402", "X402_FACILITATOR_ENABLED")
101
utils.StringFlagEnv(fs, &cfg.X402Network, "x402-network", "", "x402 facilitator CAIP-2 network, such as eip155:8453", "X402_NETWORK")
102
- utils.StringFlagEnv(fs, &cfg.X402RPCURL, "x402-rpc-url", "", "x402 facilitator RPC URL; empty uses the facilitator default for the network when available", "X402_RPC_URL")
102
+ utils.StringFlagEnv(fs, &cfg.X402RPCURL, "x402-rpc-url", "", "x402 facilitator RPC URL; empty uses the PublicNode default for supported networks", "X402_RPC_URL")
103
104
utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (cloudflare|gcloud|hetzner|njalla|route53|vultr); leave empty to use manual fullchain.pem/privatekey.pem from IDENTITY_PATH", "ACME_DNS_PROVIDER")
105
utils.BoolFlagEnv(fs, &cfg.ENSGaslessEnabled, "ens-gasless-enabled", false, "enable ENS gasless DNS import automation for the managed DNS zone and lease hostnames", "ENS_GASLESS_ENABLED")
docs/src/routes/cli-reference/+page.md
+1
-2
@@ -102,9 +102,8 @@ not supported.
102
| `--x402-price` | string | | x402 route price, such as `$0.001` |
103
| `--x402-pay-to` | string | identity | x402 recipient address; empty uses the tunnel identity address |
104
| `--x402-facilitator-url` | string | | x402 facilitator URL |
105
-| `--x402-resource` | string | route prefix | x402 protected resource/root path |
105
+| `--x402-resource` | string | requested URL | x402 protected resource URL |
106
| `--x402-mime-type` | string | | x402 protected resource MIME type |
107
-| `--x402-testnet` | bool | `false` | Render the x402 paywall in testnet mode |
107
| `--x402-max-timeout` | int | `0` | x402 max payment timeout seconds advertised to clients |
108
| `--x402-payment-timeout` | int | `0` | x402 middleware verify/settle timeout seconds |
109
| `--tcp` | bool | `false` | Request a dedicated raw TCP port on the relay |
docs/src/routes/configuration/+page.md
-1
@@ -276,7 +276,6 @@ pay_to = "identity"
276
facilitator_url = "https://portal.example.com:4017/x402"
277
resource = "/api/report"
278
mime_type = "application/json"
279
-testnet = false
279
max_timeout_seconds = 0
280
payment_timeout_seconds = 0
281
```
frontend/src/components/Header.tsx
+4
-11
@@ -45,10 +45,6 @@ function formatWalletAddress(address: string): string {
45
return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;
46
}
47
48
-function facilitatorURL(x402: X402FacilitatorInfo): string {
49
- return x402.url?.trim() || "/x402";
50
-}
51
-
48
function facilitatorNetworkLabel(x402: X402FacilitatorInfo): string {
49
return x402.network_name?.trim() || x402.network?.trim() || "enabled";
50
}
@@ -154,15 +150,12 @@ export function Header({
150
</span>
151
)}
152
{x402 && (
157
- <a
158
- href={facilitatorURL(x402)}
159
- target="_blank"
160
- rel="noopener noreferrer"
161
- className="inline-flex h-6 max-w-40 items-center overflow-hidden text-ellipsis whitespace-nowrap rounded-full bg-emerald-500/10 px-2.5 text-xs font-semibold text-emerald-700 ring-1 ring-emerald-500/20 transition-colors hover:bg-emerald-500/15 dark:text-emerald-300"
162
- title={`x402 facilitator: ${facilitatorURL(x402)}`}
153
+ <span
154
+ className="inline-flex h-6 max-w-40 cursor-default items-center overflow-hidden text-ellipsis whitespace-nowrap rounded-full bg-emerald-500/10 px-2.5 text-xs font-semibold text-emerald-700 ring-1 ring-emerald-500/20 dark:text-emerald-300"
155
+ title="x402 facilitator enabled"
156
>
157
x402 {facilitatorNetworkLabel(x402)}
165
- </a>
158
+ </span>
159
)}
160
</div>
161
</div>
portal/x402/x402.go
+28
-6
@@ -33,10 +33,31 @@ var networkDisplayNames = map[string]string{
33
"eip155:421614": "Arbitrum Sepolia",
34
}
35
36
+var networkPublicNodeRPCURLs = map[string]string{
37
+ "eip155:1": "https://ethereum-rpc.publicnode.com",
38
+ "eip155:8453": "https://base-rpc.publicnode.com",
39
+ "eip155:84532": "https://base-sepolia-rpc.publicnode.com",
40
+ "eip155:42161": "https://arbitrum-one-rpc.publicnode.com",
41
+ "eip155:421614": "https://arbitrum-sepolia-rpc.publicnode.com",
42
+}
43
+
44
func NetworkDisplayName(network string) string {
45
return networkDisplayNames[strings.TrimSpace(strings.ToLower(network))]
46
}
47
48
+func PublicNodeRPCURL(network string) string {
49
+ return networkPublicNodeRPCURLs[strings.TrimSpace(strings.ToLower(network))]
50
+}
51
+
52
+func isTestnetNetwork(network string) bool {
53
+ switch strings.TrimSpace(strings.ToLower(network)) {
54
+ case "eip155:84532", "eip155:421614":
55
+ return true
56
+ default:
57
+ return false
58
+ }
59
+}
60
+
61
type FacilitatorConfig struct {
62
Network string
63
RPCURL string
@@ -55,7 +76,11 @@ func MountFacilitator(mux *http.ServeMux, cfg FacilitatorConfig) error {
76
if privateKey == "" {
77
return errors.New("relay identity private key is required when --x402-facilitator-enabled is set")
78
}
58
- facilitator, err := facilitatorcore.NewFacilitator(facilitatortypes.Exact, network, strings.TrimSpace(cfg.RPCURL), privateKey)
79
+ rpcURL := strings.TrimSpace(cfg.RPCURL)
80
+ if rpcURL == "" {
81
+ rpcURL = PublicNodeRPCURL(network)
82
+ }
83
+ facilitator, err := facilitatorcore.NewFacilitator(facilitatortypes.Exact, network, rpcURL, privateKey)
84
if err != nil {
85
return fmt.Errorf("create x402 facilitator: %w", err)
86
}
@@ -134,9 +159,6 @@ func NewHTTPRouteHandler(cfg HTTPRouteHandlerConfig) (http.Handler, error) {
159
timeout = time.Duration(cfg.X402.PaymentTimeoutSecs) * time.Second
160
}
161
resource := strings.TrimSpace(cfg.X402.Resource)
137
- if resource == "" {
138
- resource = prefix
139
- }
162
description := strings.TrimSpace(cfg.Metadata.Description)
163
if description == "" {
164
description = defaultRouteDescription
@@ -163,14 +185,14 @@ func NewHTTPRouteHandler(cfg HTTPRouteHandlerConfig) (http.Handler, error) {
185
}),
186
Schemes: []x402nethttp.SchemeConfig{
187
{
166
- Network: foundationx402.Network("eip155:*"),
188
+ Network: foundationx402.Network(network),
189
Server: evmserver.NewExactEvmScheme(),
190
},
191
},
192
PaywallConfig: &x402http.PaywallConfig{
193
AppName: strings.TrimSpace(cfg.TunnelIdentity.Name),
194
AppLogo: strings.TrimSpace(cfg.Metadata.Thumbnail),
173
- Testnet: cfg.X402.Testnet,
195
+ Testnet: isTestnetNetwork(network),
196
},
197
SyncFacilitatorOnStart: true,
198
Timeout: timeout,
types/x402.go
-2
@@ -12,7 +12,6 @@ type X402Config struct {
12
FacilitatorURL string `json:"facilitator_url,omitempty" koanf:"facilitator_url"`
13
Resource string `json:"resource,omitempty" koanf:"resource"`
14
MimeType string `json:"mime_type,omitempty" koanf:"mime_type"`
15
- Testnet bool `json:"testnet,omitempty" koanf:"testnet"`
15
MaxTimeoutSeconds int `json:"max_timeout_seconds,omitempty" koanf:"max_timeout_seconds"`
16
PaymentTimeoutSecs int `json:"payment_timeout_seconds,omitempty" koanf:"payment_timeout_seconds"`
17
}
@@ -24,7 +23,6 @@ func (c X402Config) Empty() bool {
23
c.FacilitatorURL == "" &&
24
c.Resource == "" &&
25
c.MimeType == "" &&
27
- !c.Testnet &&
26
c.MaxTimeoutSeconds == 0 &&
27
c.PaymentTimeoutSecs == 0
28
}