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 {