feat(x402): add x402 payment recipient configuration and related documentation

rabbitprincess committed Jun 3, 2026 at 19:00 UTC 92abcc179f54e16eadaef726524ebebdb374dea3
21 files changed +152 -31
.env.example
+1
@@ -59,6 +59,7 @@ ADMIN_TOKEN=
59 # Optional embedded Sui x402 facilitator exposed under /api/x402.
60 X402_ENABLED=true
61 X402_TESTNET=true
62 +X402_PAY_TO=
63
64 # Enable when the relay is behind nginx/ingress/load balancers and should trust forwarded client IP headers.
65 # Optionally restrict which proxy source ranges may supply those headers; leave empty for default private/loopback proxy ranges.
cmd/portal-tunnel/README.md
+6 -1
@@ -153,6 +153,8 @@ Mode constraints:
153 - `--http-route` cannot be combined with `--udp`.
154 - Multi-hop currently supports only the default SNI TLS stream transport.
155 - `--multi-hop` cannot be combined with automatic `--multi-hop-depth`.
156 +- `--x402-price` applies only to routed HTTP prefixes and requires a
157 + tunnel-owned `--x402-pay-to`.
158
159 Common flags:
160
@@ -171,6 +173,8 @@ Common flags:
173 --thumbnail Service thumbnail URL metadata
174 --owner Service owner metadata
175 --hide Hide service from relay listing screens
176 +--x402-pay-to Sui payment recipient address for this tunnel
177 +--x402-price Sui x402 price mapping in PATH=PRICE form; repeatable
178 --http-route HTTP route mapping in PATH=UPSTREAM form; repeatable
179 --tcp Request a dedicated raw TCP port on the relay
180 --udp Enable public UDP relay in addition to the default stream path
@@ -271,8 +275,9 @@ id = "frontend"
275 name = "myapp-http"
276 relays = ["https://portal.example.com"]
277 discovery = false
278 +x402_pay_to = "0x..."
279 http_routes = [
275 - { prefix = "/api", upstream = "http://127.0.0.1:3001" },
280 + { prefix = "/api", upstream = "http://127.0.0.1:3001", x402_price = "100000" },
281 { prefix = "/", upstream = "http://127.0.0.1:5173" },
282 ]
283 ```
cmd/portal-tunnel/agent/config.go
+9 -2
@@ -59,11 +59,13 @@ type TunnelConfig struct {
59 Owner string `koanf:"owner"`
60 Thumbnail string `koanf:"thumbnail"`
61 Hide bool `koanf:"hide"`
62 + X402PayTo string `koanf:"x402_pay_to"`
63 }
64
65 type HTTPRouteConfig struct {
65 - Prefix string `koanf:"prefix"`
66 - Upstream string `koanf:"upstream"`
66 + Prefix string `koanf:"prefix"`
67 + Upstream string `koanf:"upstream"`
68 + X402Price string `koanf:"x402_price"`
69 }
70
71 func LoadExistingConfig(path string) (Config, error) {
@@ -177,6 +179,7 @@ func tunnelConfigDocumentMap(cfg TunnelConfig) map[string]any {
179 routeMap := make(map[string]any)
180 addStringDocumentField(routeMap, "prefix", route.Prefix)
181 addStringDocumentField(routeMap, "upstream", route.Upstream)
182 + addStringDocumentField(routeMap, "x402_price", route.X402Price)
183 routes = append(routes, routeMap)
184 }
185 out["http_routes"] = routes
@@ -211,6 +214,7 @@ func tunnelConfigDocumentMap(cfg TunnelConfig) map[string]any {
214 if cfg.Hide {
215 out["hide"] = cfg.Hide
216 }
217 + addStringDocumentField(out, "x402_pay_to", cfg.X402PayTo)
218 return out
219 }
220
@@ -355,6 +359,9 @@ func (cfg TunnelConfig) Validate() error {
359 if strings.TrimSpace(route.Prefix) == "" || strings.TrimSpace(route.Upstream) == "" {
360 return fmt.Errorf("tunnel %q http_routes require prefix and upstream", cfg.ID)
361 }
362 + if strings.TrimSpace(route.X402Price) != "" && strings.TrimSpace(cfg.X402PayTo) == "" {
363 + return fmt.Errorf("tunnel %q http route %q x402_price requires x402_pay_to", cfg.ID, strings.TrimSpace(route.Prefix))
364 + }
365 }
366 return nil
367 }
cmd/portal-tunnel/agent/manager.go
+4 -2
@@ -661,6 +661,7 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
661 BanMITM: banMITM,
662 MaxActiveRelays: cfg.MaxActiveRelays,
663 Metadata: metadataFromTunnelConfig(cfg),
664 + X402PayTo: cfg.X402PayTo,
665 })
666 if err != nil {
667 return err
@@ -684,8 +685,9 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
685 routes := make([]sdk.HTTPRoute, 0, len(cfg.HTTPRoutes))
686 for _, route := range cfg.HTTPRoutes {
687 routes = append(routes, sdk.HTTPRoute{
687 - Prefix: route.Prefix,
688 - Upstream: route.Upstream,
688 + Prefix: route.Prefix,
689 + Upstream: route.Upstream,
690 + X402Price: route.X402Price,
691 })
692 }
693 err = exposure.RunHTTPRoutes(ctx, routes, "")
cmd/portal-tunnel/main.go
+67 -12
@@ -59,6 +59,8 @@ type exposeFlags struct {
59 owner string
60 thumbnail string
61 hide bool
62 + x402PayTo string
63 + x402Prices []string
64 targetAddr string
65 httpRoutes []string
66 udp bool
@@ -87,6 +89,8 @@ func runExposeCommand(args []string) error {
89 utils.StringFlag(fs, &flags.owner, "owner", "", "Service owner metadata")
90 utils.StringFlag(fs, &flags.thumbnail, "thumbnail", "", "Service thumbnail URL metadata")
91 utils.BoolFlag(fs, &flags.hide, "hide", false, "Hide service from relay listing screens")
92 + utils.StringFlag(fs, &flags.x402PayTo, "x402-pay-to", "", "Sui payment recipient address for this tunnel")
93 + utils.RepeatedStringFlag(fs, &flags.x402Prices, "x402-price", "Sui x402 price mapping in PATH=PRICE form; repeat to price multiple HTTP routes")
94 utils.RepeatedStringFlag(fs, &flags.httpRoutes, "http-route", "HTTP route mapping in PATH=UPSTREAM form; repeat to aggregate multiple local HTTP services behind one public URL")
95 utils.BoolFlagEnv(fs, &flags.udp, "udp", false, "Enable public UDP relay in addition to the default TCP relay", "UDP_ENABLED")
96 utils.StringFlagEnv(fs, &flags.udpAddr, "udp-addr", "", "Local UDP target address for relayed datagrams (host:port or port only); defaults to the target when --udp is enabled", "UDP_ADDR")
@@ -119,6 +123,68 @@ func runExposeCommand(args []string) error {
123 case len(httpRouteInputs) > 0 && flags.udp:
124 printExposeUsage(os.Stderr)
125 return errors.New("--udp cannot be combined with --http-route")
126 + case len(flags.x402Prices) > 0 && len(httpRouteInputs) == 0:
127 + printExposeUsage(os.Stderr)
128 + return errors.New("--x402-price requires --http-route")
129 + case len(flags.x402Prices) > 0 && strings.TrimSpace(flags.x402PayTo) == "":
130 + printExposeUsage(os.Stderr)
131 + return errors.New("--x402-price requires --x402-pay-to")
132 + }
133 +
134 + x402Prices := make(map[string]string, len(flags.x402Prices))
135 + for _, raw := range flags.x402Prices {
136 + prefix, price, ok := strings.Cut(raw, "=")
137 + if !ok {
138 + return fmt.Errorf("--x402-price %q: expected PATH=PRICE", raw)
139 + }
140 + prefix = strings.TrimSpace(prefix)
141 + if prefix == "" {
142 + return fmt.Errorf("--x402-price %q: path is required", raw)
143 + }
144 + if !strings.HasPrefix(prefix, "/") {
145 + return fmt.Errorf("--x402-price %q: path must start with /", raw)
146 + }
147 + prefix = utils.NormalizeURLPath(prefix)
148 + price = strings.TrimSpace(price)
149 + if price == "" {
150 + return fmt.Errorf("--x402-price %q: price is required", raw)
151 + }
152 + if _, exists := x402Prices[prefix]; exists {
153 + return fmt.Errorf("--x402-price path %q repeated", prefix)
154 + }
155 + x402Prices[prefix] = price
156 + }
157 +
158 + httpRoutes := make([]sdk.HTTPRoute, 0, len(httpRouteInputs))
159 + for _, raw := range httpRouteInputs {
160 + prefix, upstream, ok := strings.Cut(raw, "=")
161 + if !ok {
162 + return fmt.Errorf("--http-route %q: expected PATH=UPSTREAM", raw)
163 + }
164 + prefix = strings.TrimSpace(prefix)
165 + if prefix == "" {
166 + return fmt.Errorf("--http-route %q: path is required", raw)
167 + }
168 + if !strings.HasPrefix(prefix, "/") {
169 + return fmt.Errorf("--http-route %q: path must start with /", raw)
170 + }
171 + upstream = strings.TrimSpace(upstream)
172 + if upstream == "" {
173 + return fmt.Errorf("--http-route %q: upstream is required", raw)
174 + }
175 + normalizedPrefix := utils.NormalizeURLPath(prefix)
176 + route := sdk.HTTPRoute{
177 + Prefix: prefix,
178 + Upstream: upstream,
179 + }
180 + if price, ok := x402Prices[normalizedPrefix]; ok {
181 + route.X402Price = price
182 + delete(x402Prices, normalizedPrefix)
183 + }
184 + httpRoutes = append(httpRoutes, route)
185 + }
186 + for prefix := range x402Prices {
187 + return fmt.Errorf("--x402-price path %q has no matching --http-route", prefix)
188 }
189
190 ctx, stop := utils.SignalContext()
@@ -161,23 +227,12 @@ func runExposeCommand(args []string) error {
227 Thumbnail: flags.thumbnail,
228 Hide: flags.hide,
229 },
230 + X402PayTo: flags.x402PayTo,
231 })
232 if err != nil {
233 return fmt.Errorf("failed to start relays: %w", err)
234 }
235 if len(httpRouteInputs) > 0 {
169 - httpRoutes := make([]sdk.HTTPRoute, 0, len(httpRouteInputs))
170 - for _, raw := range httpRouteInputs {
171 - prefix, upstream, ok := strings.Cut(raw, "=")
172 - if !ok {
173 - return fmt.Errorf("--http-route %q: expected PATH=UPSTREAM", raw)
174 - }
175 - httpRoutes = append(httpRoutes, sdk.HTTPRoute{
176 - Prefix: strings.TrimSpace(prefix),
177 - Upstream: strings.TrimSpace(upstream),
178 - })
179 - }
180 -
236 defer exposure.Close()
237 return exposure.RunHTTPRoutes(ctx, httpRoutes, "")
238 }
cmd/relay-server/main.go
+5 -3
@@ -52,6 +52,7 @@ type relayServerConfig struct {
52 PProfAddr string
53 X402Enabled bool
54 X402Testnet bool
55 + X402PayTo string
56
57 ACMEDNSProvider string
58 ENSGaslessEnabled bool
@@ -94,6 +95,7 @@ func runServeCommand(args []string) error {
95 utils.StringFlagEnv(fs, &cfg.PProfAddr, "pprof-addr", portal.DefaultPProfListenAddr, "pprof diagnostics listen address when enabled", "PPROF_ADDR")
96 utils.BoolFlagEnv(fs, &cfg.X402Enabled, "x402-enabled", false, "enable embedded Sui x402 facilitator endpoints under /api/x402", "X402_ENABLED")
97 utils.BoolFlagEnv(fs, &cfg.X402Testnet, "x402-testnet", false, "use Sui testnet for embedded x402 facilitator payments", "X402_TESTNET")
98 + utils.StringFlagEnv(fs, &cfg.X402PayTo, "x402-pay-to", "", "Sui payment recipient address for relay-owned x402 resources", "X402_PAY_TO")
99
100 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")
101 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")
@@ -142,6 +144,7 @@ func runServeCommand(args []string) error {
144 Str("pprof_addr", cfg.PProfAddr).
145 Bool("x402_facilitator_enabled", cfg.X402Enabled).
146 Bool("x402_testnet", cfg.X402Testnet).
147 + Bool("x402_pay_to_configured", strings.TrimSpace(cfg.X402PayTo) != "").
148 Str("acme_dns_provider", cfg.ACMEDNSProvider).
149 Bool("ens_gasless_enabled", cfg.ENSGaslessEnabled).
150 Msg("configured relay server")
@@ -171,6 +174,7 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
174 PProfListenAddr: cfg.PProfAddr,
175 X402Enabled: cfg.X402Enabled,
176 X402Testnet: cfg.X402Testnet,
177 + X402PayTo: cfg.X402PayTo,
178 ACME: acme.Config{
179 KeyDir: cfg.IdentityPath,
180 DNSProvider: cfg.ACMEDNSProvider,
@@ -201,10 +205,8 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
205 apiMux := relayAPI.Handler()
206 if cfg.X402Enabled {
207 x402Network := portalx402.Network(cfg.X402Testnet)
204 - relayIdentity := server.RelayIdentity()
208 if err := portalx402.MountFacilitator(apiMux, portalx402.FacilitatorConfig{
206 - Identity: relayIdentity.Identity,
207 - Testnet: cfg.X402Testnet,
209 + Testnet: cfg.X402Testnet,
210 }); err != nil {
211 return fmt.Errorf("mount x402 facilitator: %w", err)
212 }
docker-compose.yml
+1
@@ -110,6 +110,7 @@ services:
110 ADMIN_TOKEN: ${ADMIN_TOKEN:-}
111 X402_ENABLED: ${X402_ENABLED:-true}
112 X402_TESTNET: ${X402_TESTNET:-true}
113 + X402_PAY_TO: ${X402_PAY_TO:-}
114 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
115 TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
116
docs/src/routes/api-reference/+page.md
+2
@@ -118,6 +118,8 @@ These Sui-only endpoints are available only when
118 `X402_ENABLED=true`. They are served by the embedded
119 `gosuda/x402-facilitator` handler and do not use the Portal JSON envelope.
120 Portal selects Sui mainnet by default and Sui testnet when `X402_TESTNET=true`.
121 +`X402_PAY_TO` is only the relay-owned payment recipient. Tunnel payment
122 +settings are local tunnel configuration and are not part of the relay lease API.
123
124 | Method | Path | Auth | Body | Response |
125 |--------|------|------|------|----------|
docs/src/routes/api-reference/sdk/+page.md
+1 -1
@@ -53,7 +53,7 @@ that switches to a raw stream after a successful HTTP/1.1 response.
53 | Field | Type |
54 |-------|------|
55 | `enabled` | `boolean` |
56 -| `url`, `network`, `network_name`, `supported_url` | `string` |
56 +| `url`, `network`, `network_name`, `supported_url`, `pay_to` | `string` |
57
58 ## Register Challenge
59
docs/src/routes/cli-reference/+page.md
+15
@@ -97,6 +97,8 @@ not supported.
97 | `--thumbnail` | string | | Service thumbnail URL metadata |
98 | `--owner` | string | | Service owner metadata |
99 | `--hide` | bool | `false` | Hide service from relay listing screens |
100 +| `--x402-pay-to` | string | | Sui payment recipient address for this tunnel |
101 +| `--x402-price` | string | | Sui x402 price mapping in `PATH=PRICE` form; repeatable; requires `--http-route` and `--x402-pay-to` |
102 | `--http-route` | string | | HTTP route mapping in `PATH=UPSTREAM` form; repeatable |
103 | `--tcp` | bool | `false` | Request a dedicated raw TCP port on the relay |
104 | `--udp` | bool | `false` | Enable public UDP relay in addition to the default stream path |
@@ -110,6 +112,9 @@ not supported.
112 - Explicit `--multi-hop` cannot be combined with automatic `--multi-hop-depth`.
113 - Multi-hop currently supports only the default SNI TLS stream transport.
114 - `--tcp` and `--udp` require matching transport support on the relay.
115 +- `--x402-price` applies only to routed HTTP prefixes and requires a
116 + tunnel-owned `--x402-pay-to`; relay `X402_PAY_TO` is not used as a tunnel
117 + default.
118
119 ### Examples
120
@@ -171,6 +176,16 @@ Ban relays on MITM probe detection:
176 portal expose 3000 --ban-mitm
177 ```
178
179 +Publish a paid HTTP route:
180 +
181 +```bash
182 +portal expose --name myapp \
183 + --http-route /api=http://127.0.0.1:3001 \
184 + --http-route /=http://127.0.0.1:5173 \
185 + --x402-pay-to 0x... \
186 + --x402-price /api=100000
187 +```
188 +
189 ## `portal list`
190
191 Print relay URLs resolved for the current invocation:
docs/src/routes/configuration/+page.md
+7
@@ -43,6 +43,7 @@ The relay server (`relay-server`) reads configuration from environment variables
43 |----------|---------|------|-------------|
44 | `X402_ENABLED` | `false` | bool | Enable embedded Sui x402 facilitator endpoints under `/api/x402` |
45 | `X402_TESTNET` | `false` | bool | Use Sui testnet for payments; `false` uses Sui mainnet |
46 +| `X402_PAY_TO` | `""` | string | Sui payment recipient address for relay-owned x402 resources |
47
48 ### Proxy
49
@@ -161,6 +162,8 @@ The `portal expose` subcommand accepts the following flags. Flags that read from
162 | `--owner` | | string | | Service owner metadata |
163 | `--thumbnail` | | string | | Service thumbnail URL metadata |
164 | `--hide` | | bool | `false` | Hide service from relay listing screens |
165 +| `--x402-pay-to` | | string | | Sui payment recipient address for this tunnel |
166 +| `--x402-price` | | string | | Sui x402 price mapping in `PATH=PRICE` form; repeatable; requires `--http-route` and `--x402-pay-to` |
167
168 ### Routing
169
@@ -218,10 +221,12 @@ tags = ["web"]
221 [[tunnels]]
222 id = "api"
223 name = "myapp"
224 +x402_pay_to = "0x..."
225
226 [[tunnels.http_routes]]
227 prefix = "/api"
228 upstream = "http://127.0.0.1:3001"
229 +x402_price = "100000"
230
231 [[tunnels.http_routes]]
232 prefix = "/"
@@ -256,6 +261,8 @@ Tunnel fields mirror `portal expose` flags:
261 | `identity_json` | string | Identity JSON payload; overrides `identity_path` contents and is persisted there when both are set |
262 | `udp`, `udp_addr`, `tcp` | bool/string | UDP and raw TCP relay options |
263 | `description`, `tags`, `owner`, `thumbnail`, `hide` | mixed | Lease metadata shown by relays |
264 +| `x402_pay_to` | string | Tunnel-owned Sui x402 payment recipient for priced HTTP routes |
265 +| `http_routes[].x402_price` | string | Optional Sui x402 price for one HTTP route prefix; requires `x402_pay_to` |
266 For a task-oriented walkthrough, see [Portal Agent](/portal-agent).
267
268 ### `identity.json`
docs/src/routes/portal-agent/+page.md
+6
@@ -70,10 +70,12 @@ id = "frontend"
70 name = "myapp"
71 relays = ["https://portal.example.com"]
72 discovery = false
73 +x402_pay_to = "0x..."
74
75 [[tunnels.http_routes]]
76 prefix = "/api"
77 upstream = "http://127.0.0.1:3001"
78 +x402_price = "100000"
79
80 [[tunnels.http_routes]]
81 prefix = "/"
@@ -172,6 +174,8 @@ Common fields:
174 | `multi_hop_depth` | Automatically choose one multi-hop route with this depth |
175 | `ban_mitm` | Ban relays when the TLS self-probe detects termination; defaults to warning-only |
176 | `description`, `tags`, `owner`, `thumbnail`, `hide` | Public relay metadata |
177 +| `x402_pay_to` | Tunnel-owned Sui x402 recipient for priced HTTP routes |
178 +| `http_routes[].x402_price` | Optional Sui x402 price for one HTTP route prefix |
179
180 Constraints match `portal expose`:
181
@@ -181,6 +185,8 @@ Constraints match `portal expose`:
185 - `multi_hop` cannot be combined with `multi_hop_depth`.
186 - Multi-hop currently supports only the default stream transport, not UDP or raw
187 TCP port mode.
188 +- `http_routes[].x402_price` requires `x402_pay_to`; relay `X402_PAY_TO` is
189 + independent and is not used as a tunnel default.
190
191 ## Identity Layout
192
docs/src/routes/self-hosting/+page.md
+4 -1
@@ -95,11 +95,14 @@ the embedded handler. Payments use Sui mainnet by default; set
95 environment:
96 X402_ENABLED: "true"
97 X402_TESTNET: "false"
98 + X402_PAY_TO: "0x..."
99 ```
100
101 This serves `/api/x402/supported`, `/api/x402/verify`, and `/api/x402/settle`.
102 Portal payments intentionally support only Sui mainnet and testnet. Route-level
102 -payment enforcement is configured separately from the facilitator endpoint.
103 +payment enforcement is configured separately from the facilitator endpoint. The
104 +relay `X402_PAY_TO` address is for relay-owned resources; tunnel apps set their
105 +own recipient with `portal expose --x402-pay-to`.
106
107 ## Connecting Your Tunnel
108
frontend/src/types/api.ts
+1
@@ -101,6 +101,7 @@ export interface X402FacilitatorInfo {
101 network?: string;
102 network_name?: string;
103 supported_url?: string;
104 + pay_to?: string;
105 }
106
107 export interface DomainResponse {
portal/api_server.go
+1
@@ -258,6 +258,7 @@ func (s *Server) handleDomain(w http.ResponseWriter, r *http.Request) {
258 x402Info.Network = network
259 x402Info.NetworkName = x402.NetworkDisplayName(network)
260 x402Info.SupportedURL = baseURL + types.X402SupportedPath
261 + x402Info.PayTo = cfg.X402PayTo
262 }
263
264 utils.WriteAPIData(w, http.StatusOK, types.DomainResponse{
portal/server.go
+2
@@ -58,6 +58,7 @@ type ServerConfig struct {
58 PProfListenAddr string
59 X402Enabled bool
60 X402Testnet bool
61 + X402PayTo string
62 ACME acme.Config
63 }
64
@@ -92,6 +93,7 @@ func normalizeServerConfig(cfg ServerConfig) (ServerConfig, error) {
93 if cfg.PProfEnabled {
94 cfg.PProfListenAddr = utils.StringOrDefault(strings.TrimSpace(cfg.PProfListenAddr), DefaultPProfListenAddr)
95 }
96 + cfg.X402PayTo = strings.TrimSpace(cfg.X402PayTo)
97 hasPortRange := cfg.MinPort > 0 && cfg.MaxPort > 0
98 if cfg.UDPEnabled || cfg.TCPEnabled {
99 switch {
portal/x402/x402.go
+2 -4
@@ -34,16 +34,14 @@ func NetworkDisplayName(network string) string {
34 }
35
36 type FacilitatorConfig struct {
37 - Identity types.Identity
38 - Testnet bool
37 + Testnet bool
38 }
39
40 func MountFacilitator(mux *http.ServeMux, cfg FacilitatorConfig) error {
41 if mux == nil {
42 return errors.New("x402 facilitator requires an api mux")
43 }
45 - privateKey := strings.TrimSpace(cfg.Identity.PrivateKey)
46 - facilitator, err := facilitatorcore.NewSuiFacilitator(Network(cfg.Testnet), "", privateKey)
44 + facilitator, err := facilitatorcore.NewSuiFacilitator(Network(cfg.Testnet), "", "")
45 if err != nil {
46 return fmt.Errorf("create sui x402 facilitator: %w", err)
47 }
sdk/expose.go
+5 -1
@@ -56,6 +56,7 @@ type ExposeConfig struct {
56 BanMITM bool
57 MaxActiveRelays int
58 Metadata types.LeaseMetadata
59 + X402PayTo string
60 }
61
62 func (cfg ExposeConfig) snapshot() ExposeConfig {
@@ -63,6 +64,7 @@ func (cfg ExposeConfig) snapshot() ExposeConfig {
64 cfg.Identity = cfg.Identity.Copy()
65 cfg.MultiHop = utils.CloneSlice(cfg.MultiHop)
66 cfg.Metadata = cfg.Metadata.Copy()
67 + cfg.X402PayTo = strings.TrimSpace(cfg.X402PayTo)
68 return cfg
69 }
70
@@ -96,6 +98,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
98 if (len(multiHop) > 0 || cfg.MultiHopDepth > 1) && (cfg.UDPEnabled || cfg.TCPEnabled) {
99 return nil, errors.New("multi-hop currently supports only the default SNI TLS stream transport")
100 }
101 + x402PayTo := strings.TrimSpace(cfg.X402PayTo)
102
103 var initialRouteCount int
104 var relaySetURLs []string
@@ -149,6 +152,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
152 runtimeCfg.UDPAddr = udpAddr
153 runtimeCfg.MultiHop = append([]string(nil), multiHop...)
154 runtimeCfg.Metadata = cfg.Metadata.Copy()
155 + runtimeCfg.X402PayTo = x402PayTo
156
157 exposureCtx, cancel := context.WithCancel(ctx)
158 exposure := &Exposure{
@@ -521,7 +525,7 @@ func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
525 // RunHTTPRoutes serves path-routed HTTP upstreams through the exposure.
526 func (e *Exposure) RunHTTPRoutes(ctx context.Context, routes []HTTPRoute, localAddr string) error {
527 cfg := e.Config()
524 - handler, err := newHTTPRouteHandler(routes, cfg.Identity, cfg.Metadata)
528 + handler, err := newHTTPRouteHandler(routes, cfg.Identity, cfg.Metadata, cfg.X402PayTo)
529 if err != nil {
530 return err
531 }
sdk/http.go
+9 -1
@@ -132,6 +132,8 @@ type HTTPRoute struct {
132 Prefix string
133 // Upstream is the target HTTP URL, or a loopback host:port shorthand.
134 Upstream string
135 + // X402Price enables Sui x402 payment for this public path prefix.
136 + X402Price string
137 }
138
139 type httpRoute struct {
@@ -141,14 +143,16 @@ type httpRoute struct {
143 upstreamPath string
144 upstreamPathSlash string
145 upstreamDomain string
146 + x402Price string
147 handler http.Handler
148 }
149
147 -func newHTTPRouteHandler(routeConfigs []HTTPRoute, tunnelIdentity types.Identity, metadata types.LeaseMetadata) (http.Handler, error) {
150 +func newHTTPRouteHandler(routeConfigs []HTTPRoute, tunnelIdentity types.Identity, metadata types.LeaseMetadata, x402PayTo string) (http.Handler, error) {
151 if len(routeConfigs) == 0 {
152 return nil, errors.New("at least one http route is required")
153 }
154
155 + x402PayTo = strings.TrimSpace(x402PayTo)
156 routes := make([]*httpRoute, 0, len(routeConfigs))
157 seen := make(map[string]struct{}, len(routeConfigs))
158 for _, routeConfig := range routeConfigs {
@@ -156,6 +160,9 @@ func newHTTPRouteHandler(routeConfigs []HTTPRoute, tunnelIdentity types.Identity
160 if err != nil {
161 return nil, err
162 }
163 + if route.x402Price != "" && x402PayTo == "" {
164 + return nil, fmt.Errorf("http route %q x402 price requires x402 pay-to", route.prefix)
165 + }
166 if _, ok := seen[route.prefix]; ok {
167 return nil, fmt.Errorf("duplicate http route prefix %q", route.prefix)
168 }
@@ -226,6 +233,7 @@ func newHTTPRoute(routeConfig HTTPRoute) (*httpRoute, error) {
233 upstream: upstream,
234 upstreamPath: upstream.Path,
235 upstreamDomain: utils.NormalizeHostname(upstream.Hostname()),
236 + x402Price: strings.TrimSpace(routeConfig.X402Price),
237 }
238 if prefix != "/" {
239 route.prefixSlash = prefix + "/"
sdk/http_test.go
+3 -3
@@ -146,7 +146,7 @@ func TestHTTPRoutesUseLongestPrefix(t *testing.T) {
146 handler, err := newHTTPRouteHandler([]HTTPRoute{
147 {Prefix: "/", Upstream: rootServer.URL},
148 {Prefix: "/api", Upstream: apiServer.URL},
149 - }, types.Identity{}, types.LeaseMetadata{})
149 + }, types.Identity{}, types.LeaseMetadata{}, "")
150 if err != nil {
151 t.Fatalf("newHTTPRouteHandler() error = %v", err)
152 }
@@ -177,7 +177,7 @@ func TestHTTPRoutesRewriteResponseHeaders(t *testing.T) {
177
178 handler, err := newHTTPRouteHandler([]HTTPRoute{
179 {Prefix: "/app", Upstream: upstreamURL + "/base"},
180 - }, types.Identity{}, types.LeaseMetadata{})
180 + }, types.Identity{}, types.LeaseMetadata{}, "")
181 if err != nil {
182 t.Fatalf("newHTTPRouteHandler() error = %v", err)
183 }
@@ -201,7 +201,7 @@ func TestHTTPRoutesRejectDuplicateNormalizedPrefixes(t *testing.T) {
201 _, err := newHTTPRouteHandler([]HTTPRoute{
202 {Prefix: "/api", Upstream: "127.0.0.1:3001"},
203 {Prefix: "/api/", Upstream: "127.0.0.1:3002"},
204 - }, types.Identity{}, types.LeaseMetadata{})
204 + }, types.Identity{}, types.LeaseMetadata{}, "")
205 if err == nil {
206 t.Fatal("newHTTPRouteHandler() error = nil, want duplicate prefix error")
207 }
types/api.go
+1
@@ -196,6 +196,7 @@ type X402FacilitatorInfo struct {
196 Network string `json:"network,omitempty"`
197 NetworkName string `json:"network_name,omitempty"`
198 SupportedURL string `json:"supported_url,omitempty"`
199 + PayTo string `json:"pay_to,omitempty"`
200 }
201
202 type ENSStatus struct {