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 },