feat(x402): implement x402 payment support for premium content and update documentation
Kim committed
May 26, 2026 at 22:59 UTC
5f3b5e603fe6ae4eff7d176c71d032392fc8d791
10 files changed
+431
-14
README.md
+8
@@ -22,6 +22,8 @@ Most tunneling services (ngrok, Cloudflare Tunnel) terminate your TLS connection
22
23
- **No Accounts, No API Keys** — Authentication uses SIWE (Sign-In with Ethereum) with a locally generated secp256k1 key pair. No email, no registration, no vendor lock-in.
24
25
+- **x402 Payments** — Turn APIs, content, and local web apps into payable internet endpoints without centralized billing gateway.
26
+
27
## Comparison
28
29
| | Portal | ngrok | Cloudflare Tunnel | frp |
@@ -68,6 +70,12 @@ portal expose --name myapp \
70
--http-route /api=http://127.0.0.1:3001 \
71
--http-route /=http://127.0.0.1:5173
72
73
+# Require x402 payment before a local HTTP upstream receives traffic
74
+portal expose 3000 --name paid-api \
75
+ --x402-facilitator-url https://portal.example.com/x402 \
76
+ --x402-network eip155:8453 \
77
+ --x402-price "$0.001"
78
+
79
# Raw TCP port (Minecraft, databases, SSH)
80
portal expose localhost:25565 --name minecraft --tcp
81
cmd/demo-app/handler.go
+90
-2
@@ -1,21 +1,38 @@
1
package main
2
3
import (
4
+ "context"
5
"embed"
6
"encoding/json"
7
+ "fmt"
8
"io/fs"
9
"net/http"
10
+ "strings"
11
"time"
12
13
"golang.org/x/net/websocket"
14
15
+ portalx402 "github.com/gosuda/portal-tunnel/v2/portal/x402"
16
"github.com/gosuda/portal-tunnel/v2/sdk"
17
+ "github.com/gosuda/portal-tunnel/v2/types"
18
)
19
20
//go:embed static
21
var staticFiles embed.FS
22
18
-func newHandler() http.Handler {
23
+type premiumContent struct {
24
+ Title string
25
+ Price string
26
+}
27
+
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"},
33
+}
34
+
35
+func newHandler(appIdentity types.Identity, metadata types.LeaseMetadata, x402Config *types.X402Config) (http.Handler, error) {
36
staticFS, _ := fs.Sub(staticFiles, "static")
37
38
mux := http.NewServeMux()
@@ -23,7 +40,64 @@ func newHandler() http.Handler {
40
mux.HandleFunc("/api/ping", handlePing)
41
mux.Handle("/ws", websocket.Handler(handleWebSocket))
42
mux.HandleFunc("/api/test-cookies", handleCookies)
26
- return mux
43
+
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
+ })
67
+ if x402Config == nil || x402Config.Empty() {
68
+ mux.HandleFunc("/api/premium", handlePaymentRequired)
69
+ mux.HandleFunc("/api/premium/", handlePaymentRequired)
70
+ return mux, nil
71
+ }
72
+
73
+ protectedPremium, err := portalx402.NewHTTPRouteHandler(portalx402.HTTPRouteHandlerConfig{
74
+ Prefix: "/api/premium",
75
+ Next: premiumHandler,
76
+ X402: *x402Config,
77
+ TunnelIdentity: appIdentity,
78
+ 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
+ },
93
+ })
94
+ if err != nil {
95
+ return nil, err
96
+ }
97
+ for path := range premiumCatalog {
98
+ mux.Handle(path, protectedPremium)
99
+ }
100
+ return mux, nil
101
}
102
103
func handlePing(w http.ResponseWriter, _ *http.Request) {
@@ -76,3 +150,17 @@ func newUDPInfoHandler(exposure *sdk.Exposure) http.Handler {
150
})
151
return mux
152
}
153
+
154
+func handlePaymentRequired(w http.ResponseWriter, r *http.Request) {
155
+ path := "/api/premium"
156
+ if r != nil && r.URL != nil && r.URL.Path != "" {
157
+ path = r.URL.Path
158
+ }
159
+ w.Header().Set("Content-Type", "application/json")
160
+ w.WriteHeader(http.StatusPaymentRequired)
161
+ _ = json.NewEncoder(w).Encode(map[string]any{
162
+ "message": "payment required",
163
+ "path": path,
164
+ "hint": "start demo-app with --x402-facilitator-url and --x402-network to enable x402 settlement",
165
+ })
166
+}
cmd/demo-app/main.go
+50
-8
@@ -9,6 +9,7 @@ import (
9
"io"
10
"net"
11
"os"
12
+ "strings"
13
14
"github.com/rs/zerolog"
15
"github.com/rs/zerolog/log"
@@ -48,6 +49,7 @@ type demoConfig struct {
49
hide bool
50
thumbnail string
51
maxActiveRelays int
52
+ x402 types.X402Config
53
}
54
55
// registerConnectivityFlags registers the relay, discovery, identity, and
@@ -73,6 +75,15 @@ 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")
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")
85
+ fs.IntVar(&cfg.x402.MaxTimeoutSeconds, "x402-max-timeout", 0, "x402 max payment timeout seconds advertised to clients")
86
+ fs.IntVar(&cfg.x402.PaymentTimeoutSecs, "x402-payment-timeout", 0, "x402 middleware verify/settle timeout seconds")
87
88
if err := utils.ParseFlagSet(fs, args, printTCPUsage); err != nil {
89
if errors.Is(err, flag.ErrHelp) {
@@ -89,6 +100,24 @@ func runTCPCommand(args []string) error {
100
return fmt.Errorf("invalid --name value: %w", err)
101
}
102
cfg.name = normalizedName
103
+ if !cfg.x402.Empty() {
104
+ switch {
105
+ case strings.TrimSpace(cfg.x402.FacilitatorURL) == "":
106
+ return errors.New("--x402-facilitator-url is required when x402 is enabled")
107
+ case strings.TrimSpace(cfg.x402.Network) == "":
108
+ return errors.New("--x402-network is required when x402 is enabled")
109
+ case cfg.x402.MaxTimeoutSeconds < 0:
110
+ return errors.New("--x402-max-timeout cannot be negative")
111
+ case cfg.x402.PaymentTimeoutSecs < 0:
112
+ return errors.New("--x402-payment-timeout cannot be negative")
113
+ }
114
+ if strings.TrimSpace(cfg.x402.Resource) == "" {
115
+ cfg.x402.Resource = "/api/premium"
116
+ }
117
+ if strings.TrimSpace(cfg.x402.MimeType) == "" {
118
+ cfg.x402.MimeType = "application/json"
119
+ }
120
+ }
121
122
ctx, stop := utils.SignalContext()
123
defer stop()
@@ -130,6 +159,13 @@ func runUDPCommand(args []string) error {
159
}
160
161
func runTCPDemo(ctx context.Context, cfg demoConfig) error {
162
+ metadata := types.LeaseMetadata{
163
+ Description: cfg.desc,
164
+ Tags: utils.SplitCSV(cfg.tags),
165
+ Owner: cfg.owner,
166
+ Thumbnail: cfg.thumbnail,
167
+ Hide: cfg.hide,
168
+ }
169
exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
170
RelayURLs: utils.SplitCSV(cfg.relayURLs),
171
Discovery: cfg.discovery,
@@ -138,13 +174,7 @@ func runTCPDemo(ctx context.Context, cfg demoConfig) error {
174
IdentityJSON: cfg.identityJSON,
175
BanMITM: cfg.banMITM,
176
MaxActiveRelays: cfg.maxActiveRelays,
141
- Metadata: types.LeaseMetadata{
142
- Description: cfg.desc,
143
- Tags: utils.SplitCSV(cfg.tags),
144
- Owner: cfg.owner,
145
- Thumbnail: cfg.thumbnail,
146
- Hide: cfg.hide,
147
- },
177
+ Metadata: metadata,
178
})
179
if err != nil {
180
return fmt.Errorf("exposure listen error: %w", err)
@@ -155,7 +185,17 @@ func runTCPDemo(ctx context.Context, cfg demoConfig) error {
185
if err != nil {
186
return fmt.Errorf("invalid --addr value %q: %w", rawAddr, err)
187
}
158
- httpHandler := newHandler()
188
+ var x402Config *types.X402Config
189
+ if !cfg.x402.Empty() {
190
+ if strings.TrimSpace(cfg.x402.PayTo) == "" {
191
+ cfg.x402.PayTo = types.X402PayToIdentity
192
+ }
193
+ x402Config = &cfg.x402
194
+ }
195
+ httpHandler, err := newHandler(exposure.Identity(), metadata, x402Config)
196
+ if err != nil {
197
+ return err
198
+ }
199
defer exposure.Close()
200
err = exposure.RunHTTP(ctx, httpHandler, cfg.addr)
201
if err != nil {
@@ -255,6 +295,7 @@ func printRootUsage(w io.Writer) {
295
"demo-app --name my-app",
296
"demo-app tcp --addr 127.0.0.1:9000",
297
"demo-app udp",
298
+ "demo-app --x402-facilitator-url https://relay.example.com:4017/x402 --x402-network eip155:8453",
299
},
300
)
301
}
@@ -269,6 +310,7 @@ func printTCPUsage(w io.Writer) {
310
"demo-app",
311
"demo-app --name my-app",
312
"demo-app tcp --addr 127.0.0.1:9000",
313
+ "demo-app --x402-facilitator-url https://relay.example.com:4017/x402 --x402-network eip155:8453",
314
},
315
)
316
}
cmd/demo-app/static/index.html
+26
-1
@@ -16,6 +16,10 @@
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>
23
<button id="testCookiesBtn">Test Cookies</button>
24
<button id="wsConnectBtn">WS Connect</button>
25
<button id="wsSendBtn" disabled>WS Send "hello"</button>
@@ -56,6 +60,27 @@
60
}
61
});
62
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
+
80
+ for (const button of document.querySelectorAll('[data-premium-path]')) {
81
+ button.addEventListener('click', () => callPremium(button.dataset.premiumPath));
82
+ }
83
+
84
document.getElementById('testCookiesBtn').addEventListener('click', async () => {
85
statusEl.textContent = 'Cookies: Testing...';
86
try {
@@ -128,4 +153,4 @@
153
</script>
154
</body>
155
131
-</html>
\ No newline at end of file
156
+</html>
cmd/demo-app/static/x402.html
new
+64
@@ -0,0 +1,64 @@
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 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>
21
+ </div>
22
+
23
+ <div class="canvas-wrapper">
24
+ <pre id="log" class="log"></pre>
25
+ </div>
26
+
27
+ <div id="status" class="status">Idle</div>
28
+ </div>
29
+
30
+ <script>
31
+ const statusEl = document.getElementById('status');
32
+ const logEl = document.getElementById('log');
33
+
34
+ function log(message) {
35
+ const time = new Date().toISOString();
36
+ logEl.textContent += `[${time}] ${message}\n`;
37
+ logEl.scrollTop = logEl.scrollHeight;
38
+ }
39
+
40
+ async function callAPI(path) {
41
+ statusEl.textContent = `${path}: Loading...`;
42
+ try {
43
+ const res = await fetch(path);
44
+ const contentType = res.headers.get('content-type') || '';
45
+ const body = contentType.includes('application/json')
46
+ ? JSON.stringify(await res.json())
47
+ : await res.text();
48
+ statusEl.textContent = `${path}: ${res.status}`;
49
+ log(`${path} -> ${res.status}`);
50
+ log(body);
51
+ } catch (err) {
52
+ statusEl.textContent = `${path}: Error`;
53
+ log(`${path} error: ${err}`);
54
+ }
55
+ }
56
+
57
+ document.getElementById('freeBtn').addEventListener('click', () => callAPI('/api/ping'));
58
+ for (const button of document.querySelectorAll('[data-api-path]')) {
59
+ button.addEventListener('click', () => callAPI(button.dataset.apiPath));
60
+ }
61
+ </script>
62
+</body>
63
+
64
+</html>
cmd/portal-tunnel/README.md
+62
@@ -95,6 +95,68 @@ 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.
97
98
+For route-specific static prices, use agent config and attach `x402` to the
99
+routes that should be paid:
100
+
101
+```toml
102
+[[tunnels]]
103
+id = "paid-site"
104
+name = "paid-site"
105
+relays = ["https://portal.example.com"]
106
+discovery = false
107
+
108
+[[tunnels.http_routes]]
109
+prefix = "/"
110
+upstream = "http://127.0.0.1:5173"
111
+
112
+[[tunnels.http_routes]]
113
+prefix = "/api/report"
114
+upstream = "http://127.0.0.1:3001"
115
+
116
+[tunnels.http_routes.x402]
117
+network = "eip155:8453"
118
+price = "$0.010"
119
+pay_to = "identity"
120
+facilitator_url = "https://portal.example.com:4017/x402"
121
+resource = "/api/report"
122
+mime_type = "application/json"
123
+
124
+[[tunnels.http_routes]]
125
+prefix = "/api/dataset"
126
+upstream = "http://127.0.0.1:3001"
127
+
128
+[tunnels.http_routes.x402]
129
+network = "eip155:8453"
130
+price = "$0.050"
131
+pay_to = "identity"
132
+facilitator_url = "https://portal.example.com:4017/x402"
133
+resource = "/api/dataset"
134
+mime_type = "application/json"
135
+```
136
+
137
+For product/catalog pricing that depends on each request, put x402 in the Go
138
+app itself and wrap the protected handler with `portal/x402`:
139
+
140
+```go
141
+protected, err := portalx402.NewHTTPRouteHandler(portalx402.HTTPRouteHandlerConfig{
142
+ Prefix: "/api/premium",
143
+ Next: premiumHandler,
144
+ X402: x402Config,
145
+ TunnelIdentity: appIdentity,
146
+ Metadata: metadata,
147
+ PriceResolver: func(ctx context.Context, req portalx402.HTTPRequestContext) (string, error) {
148
+ return catalog.PriceForPath(req.Path)
149
+ },
150
+})
151
+```
152
+
153
+`cmd/demo-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
158
+```
159
+
160
Use dedicated raw TCP mode for non-HTTP services that need a public TCP port:
161
162
```text
docs/src/routes/cli-reference/+page.md
+62
@@ -161,6 +161,68 @@ portal expose 3000 --name paid-api \
161
--x402-price "$0.001"
162
```
163
164
+With `portal expose`, the `--x402-*` flags apply one shared price to the routed
165
+HTTP handler created by that command. For route-specific prices, use agent
166
+config and attach x402 to each paid route:
167
+
168
+```toml
169
+[[tunnels]]
170
+id = "paid-site"
171
+name = "paid-site"
172
+relays = ["https://portal.example.com"]
173
+discovery = false
174
+
175
+[[tunnels.http_routes]]
176
+prefix = "/"
177
+upstream = "http://127.0.0.1:5173"
178
+
179
+[[tunnels.http_routes]]
180
+prefix = "/api/report"
181
+upstream = "http://127.0.0.1:3001"
182
+
183
+[tunnels.http_routes.x402]
184
+network = "eip155:8453"
185
+price = "$0.010"
186
+pay_to = "identity"
187
+facilitator_url = "https://portal.example.com/x402"
188
+resource = "/api/report"
189
+mime_type = "application/json"
190
+
191
+[[tunnels.http_routes]]
192
+prefix = "/api/dataset"
193
+upstream = "http://127.0.0.1:3001"
194
+
195
+[tunnels.http_routes.x402]
196
+network = "eip155:8453"
197
+price = "$0.050"
198
+pay_to = "identity"
199
+facilitator_url = "https://portal.example.com/x402"
200
+resource = "/api/dataset"
201
+mime_type = "application/json"
202
+```
203
+
204
+Native Go apps can keep pricing inside the application instead. Wrap the paid
205
+handler with `portal/x402` and provide a resolver:
206
+
207
+```go
208
+protected, err := portalx402.NewHTTPRouteHandler(portalx402.HTTPRouteHandlerConfig{
209
+ Prefix: "/api/premium",
210
+ Next: premiumHandler,
211
+ X402: x402Config,
212
+ TunnelIdentity: appIdentity,
213
+ Metadata: metadata,
214
+ PriceResolver: func(ctx context.Context, req portalx402.HTTPRequestContext) (string, error) {
215
+ return catalog.PriceForPath(req.Path)
216
+ },
217
+})
218
+```
219
+
220
+The demo app exposes the same pattern:
221
+
222
+```bash
223
+go run ./cmd/demo-app --x402-facilitator-url https://portal.example.com/x402 --x402-network eip155:8453
224
+```
225
+
226
Expose a Minecraft server:
227
228
```bash
docs/src/routes/configuration/+page.md
+35
@@ -250,6 +250,41 @@ Tunnel fields mirror `portal expose` flags:
250
| `description`, `tags`, `owner`, `thumbnail`, `hide` | mixed | Lease metadata shown by relays |
251
| `http_routes.x402` | table | x402 payment settings for one HTTP route; set `facilitator_url` explicitly or let frontend/configuration tooling write it |
252
253
+`http_routes.x402` is evaluated by the tunnel process before proxying to the
254
+upstream. Use it when a specific HTTP path should require payment:
255
+
256
+```toml
257
+[[tunnels]]
258
+id = "paid-api"
259
+name = "paid-api"
260
+relays = ["https://portal.example.com"]
261
+discovery = false
262
+
263
+[[tunnels.http_routes]]
264
+prefix = "/"
265
+upstream = "http://127.0.0.1:5173"
266
+
267
+[[tunnels.http_routes]]
268
+prefix = "/api/report"
269
+upstream = "http://127.0.0.1:3001"
270
+
271
+[tunnels.http_routes.x402]
272
+network = "eip155:8453"
273
+price = "$0.010"
274
+pay_to = "identity"
275
+facilitator_url = "https://portal.example.com:4017/x402"
276
+resource = "/api/report"
277
+mime_type = "application/json"
278
+testnet = false
279
+max_timeout_seconds = 0
280
+payment_timeout_seconds = 0
281
+```
282
+
283
+Repeat `[[tunnels.http_routes]]` with a different `x402.price` for each static
284
+priced path. If prices depend on product state, user input, or a database row,
285
+wrap the app's Go handler with `portal/x402` and use a `PriceResolver`; tunnel
286
+config is intentionally static.
287
+
288
For a task-oriented walkthrough, see [Portal Agent](/portal-agent).
289
290
### `identity.json`
docs/src/routes/self-hosting/+page.md
+11
@@ -160,6 +160,17 @@ portal expose 3000 \
160
--x402-price "$0.001"
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:
165
+
166
+```bash
167
+go run ./cmd/demo-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
172
+```
173
+
174
## Troubleshooting
175
176
**Port already in use**
portal/x402/x402.go
+23
-3
@@ -1,6 +1,7 @@
1
package x402
2
3
import (
4
+ "context"
5
"errors"
6
"fmt"
7
"net/http"
@@ -50,12 +51,17 @@ func MountFacilitator(mux *http.ServeMux, cfg FacilitatorConfig) error {
51
return nil
52
}
53
54
+type HTTPRequestContext = x402http.HTTPRequestContext
55
+
56
+type PriceResolver func(context.Context, HTTPRequestContext) (string, error)
57
+
58
type HTTPRouteHandlerConfig struct {
59
Prefix string
60
Next http.Handler
61
X402 types.X402Config
62
TunnelIdentity types.Identity
63
Metadata types.LeaseMetadata
64
+ PriceResolver PriceResolver
65
}
66
67
func NewHTTPRouteHandler(cfg HTTPRouteHandlerConfig) (http.Handler, error) {
@@ -71,10 +77,24 @@ func NewHTTPRouteHandler(cfg HTTPRouteHandlerConfig) (http.Handler, error) {
77
if network == "" {
78
return nil, fmt.Errorf("http route %q x402 network is required", prefix)
79
}
74
- price := strings.TrimSpace(cfg.X402.Price)
75
- if price == "" {
80
+ priceValue := strings.TrimSpace(cfg.X402.Price)
81
+ if priceValue == "" && cfg.PriceResolver == nil {
82
return nil, fmt.Errorf("http route %q x402 price is required", prefix)
83
}
84
+ var price any = foundationx402.Price(priceValue)
85
+ if cfg.PriceResolver != nil {
86
+ price = x402http.DynamicPriceFunc(func(ctx context.Context, req x402http.HTTPRequestContext) (foundationx402.Price, error) {
87
+ resolvedPrice, err := cfg.PriceResolver(ctx, req)
88
+ if err != nil {
89
+ return "", err
90
+ }
91
+ resolvedPrice = strings.TrimSpace(resolvedPrice)
92
+ if resolvedPrice == "" {
93
+ return "", fmt.Errorf("http route %q x402 dynamic price is empty", prefix)
94
+ }
95
+ return foundationx402.Price(resolvedPrice), nil
96
+ })
97
+ }
98
payTo := strings.TrimSpace(cfg.X402.PayTo)
99
if payTo == "" || strings.EqualFold(payTo, types.X402PayToIdentity) {
100
payTo = strings.TrimSpace(cfg.TunnelIdentity.Address)
@@ -112,7 +132,7 @@ func NewHTTPRouteHandler(cfg HTTPRouteHandlerConfig) (http.Handler, error) {
132
{
133
Scheme: types.X402SchemeExact,
134
PayTo: payTo,
115
- Price: foundationx402.Price(price),
135
+ Price: price,
136
Network: foundationx402.Network(network),
137
MaxTimeoutSeconds: cfg.X402.MaxTimeoutSeconds,
138
},