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 }