feat: add support for Sui testnet in x402 payment integration
- Updated README and documentation to reflect the new `--x402-testnet` flag for using Sui testnet. - Enhanced tunnel configuration to include `x402_testnet` boolean. - Modified dashboard and CLI to support x402 testnet settings. - Adjusted payment handling in SDK and HTTP routes to accommodate testnet configurations. - Updated API references to clarify the distinction between relay-owned and tunnel-owned x402 facilitator settings.
rabbitprincess committed
Jun 6, 2026 at 13:51 UTC
f7ae239b3bac585111bb02136c8f116e129f9604
20 files changed
+151
-64
README.zh-CN.md
+1
-1
@@ -83,7 +83,7 @@ portal expose localhost:25565 --name minecraft --tcp
83
portal expose 3000 --multi-hop-depth 3
84
```
85
86
-对于付费路由,支付策略运行在隧道进程内,而不是中继上。隧道会在同一个公共 origin 上提供 `/x402/client.js` 和 `/x402/prepare`。浏览器前端可以导入 `/x402/client.js` 并调用 `x402Fetch()`;原生客户端可以直接调用 `/x402/prepare`,用自己的 Sui 运行时签名返回的交易,并发送签名后的 `X-PAYMENT`。
86
+对于付费路由,支付策略运行在隧道进程内,而不是中继上。默认使用 Sui mainnet;加上 `--x402-testnet` 可切换到 Sui testnet,这个选择与中继自身的支付设置无关。隧道会在同一个公共 origin 上提供 `/x402/client.js` 和 `/x402/prepare`。浏览器前端可以导入 `/x402/client.js` 并调用 `x402Fetch()`;原生客户端可以直接调用 `/x402/prepare`,用自己的 Sui 运行时签名返回的交易,并发送签名后的 `X-PAYMENT`。
87
88
完整路由语法请参阅 [CLI Reference](cmd/portal-tunnel/README.md),x402 helper endpoint 请参阅 [API Reference](docs/src/routes/api-reference/+page.md#payments)。
89
cmd/portal-tunnel/README.md
+10
-7
@@ -59,7 +59,8 @@ Routed HTTP serves `/x402/client.js` and `/x402/prepare` on the tunnel origin so
59
an upstream browser frontend can run the same in-page Sui wallet payment flow as
60
the standalone payment app. Native clients should use `/x402/prepare` directly
61
and send the signed payload as `X-PAYMENT`. The tunnel still verifies and
62
-settles payment before proxying the paid route.
62
+settles payment before proxying the paid route. Paid routes use Sui mainnet by
63
+default; add `--x402-testnet` to use Sui testnet.
64
65
Raw TCP and UDP:
66
@@ -101,6 +102,7 @@ Common `portal expose` flags:
102
--hide Hide service from relay listing screens
103
--http-route HTTP route mapping in PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT] form
104
--x402-pay-to Sui USDC payment recipient address for this tunnel
105
+--x402-testnet Use Sui testnet for tunnel x402 payments
106
--tcp Request a dedicated raw TCP port on the relay
107
--udp Enable public UDP relay
108
--udp-addr Local UDP target
@@ -119,18 +121,19 @@ portal agent restart
121
```
122
123
The dashboard can edit basic tunnel settings, relays, and multi-hop routes. Add
122
-Tunnel opens a small form for name, target or HTTP routes, relays, discovery,
123
-and max active relays. After creation, routed HTTP paths, route-level x402
124
-amounts, and discovery mode are read-only in the Settings pane. Edit
125
-`http_routes`, `x402_pay_to`, or `discovery` in TOML, then restart the agent or
126
-tunnel to change them.
124
+Tunnel opens a small form for name, target or HTTP routes, x402 pay-to/testnet,
125
+relays, discovery, and max active relays. After creation, routed HTTP paths,
126
+route-level x402 amounts, payment network, and discovery mode are read-only in
127
+the Settings pane. Edit `http_routes`, `x402_pay_to`, `x402_testnet`, or
128
+`discovery` in TOML, then restart the agent or tunnel to change them.
129
130
## Constraints
131
132
- A positional `<target>` cannot be combined with `--http-route`.
133
- `--http-route` cannot be combined with `--udp`.
134
- Route payment amounts are USDC values such as `0.01`, are part of
133
- `--http-route`, and require `--x402-pay-to`.
135
+ `--http-route`, and require `--x402-pay-to`; add `--x402-testnet` for Sui
136
+ testnet, otherwise payments use Sui mainnet.
137
- `--multi-hop` cannot be combined with `--multi-hop-depth`.
138
- Multi-hop currently supports only the default SNI TLS stream transport.
139
- `--tcp` and `--udp` require matching relay transport support.
cmd/portal-tunnel/agent/config.go
+4
@@ -60,6 +60,7 @@ type TunnelConfig struct {
60
Thumbnail string `koanf:"thumbnail"`
61
Hide bool `koanf:"hide"`
62
X402PayTo string `koanf:"x402_pay_to"`
63
+ X402Testnet bool `koanf:"x402_testnet"`
64
}
65
66
type HTTPRouteConfig struct {
@@ -217,6 +218,9 @@ func tunnelConfigDocumentMap(cfg TunnelConfig) map[string]any {
218
out["hide"] = cfg.Hide
219
}
220
addStringDocumentField(out, "x402_pay_to", cfg.X402PayTo)
221
+ if cfg.X402Testnet {
222
+ out["x402_testnet"] = cfg.X402Testnet
223
+ }
224
return out
225
}
226
cmd/portal-tunnel/agent/dashboard.go
+45
-9
@@ -61,6 +61,7 @@ const (
61
agentDashboardAddFieldTarget
62
agentDashboardAddFieldHTTPRoutes
63
agentDashboardAddFieldX402PayTo
64
+ agentDashboardAddFieldX402Testnet
65
agentDashboardAddFieldRelays
66
agentDashboardAddFieldDiscovery
67
agentDashboardAddFieldMaxRelays
@@ -99,15 +100,16 @@ type agentDashboardModel struct {
100
routeDraft []string
101
draftTunnelID string
102
102
- addingTunnel bool
103
- addFocus int
104
- addName textinput.Model
105
- addTarget textinput.Model
106
- addHTTPRoutes textinput.Model
107
- addX402PayTo textinput.Model
108
- addRelays textinput.Model
109
- addDiscovery textinput.Model
110
- addMaxRelays textinput.Model
103
+ addingTunnel bool
104
+ addFocus int
105
+ addName textinput.Model
106
+ addTarget textinput.Model
107
+ addHTTPRoutes textinput.Model
108
+ addX402PayTo textinput.Model
109
+ addX402Testnet textinput.Model
110
+ addRelays textinput.Model
111
+ addDiscovery textinput.Model
112
+ addMaxRelays textinput.Model
113
114
settingsEditTunnelID string
115
settingsFocus int
@@ -176,6 +178,7 @@ func RunDashboard(configPath, stateDir string) error {
178
addTarget: newAgentDashboardInlineInput("3000"),
179
addHTTPRoutes: newAgentDashboardInlineInput("/paid=3001 GET:0.01; /=5173"),
180
addX402PayTo: newAgentDashboardInlineInput("0x..."),
181
+ addX402Testnet: newAgentDashboardInlineInput("false"),
182
addRelays: newAgentDashboardInlineInput("https://portal.example.com"),
183
addDiscovery: newAgentDashboardInlineInput("true"),
184
addMaxRelays: newAgentDashboardInlineInput("3"),
@@ -743,6 +746,8 @@ func (m *agentDashboardModel) focusedAddTunnelInput() *textinput.Model {
746
return &m.addHTTPRoutes
747
case agentDashboardAddFieldX402PayTo:
748
return &m.addX402PayTo
749
+ case agentDashboardAddFieldX402Testnet:
750
+ return &m.addX402Testnet
751
case agentDashboardAddFieldRelays:
752
return &m.addRelays
753
case agentDashboardAddFieldDiscovery:
@@ -760,6 +765,7 @@ func (m *agentDashboardModel) blurAddTunnelInputs() {
765
&m.addTarget,
766
&m.addHTTPRoutes,
767
&m.addX402PayTo,
768
+ &m.addX402Testnet,
769
&m.addRelays,
770
&m.addDiscovery,
771
&m.addMaxRelays,
@@ -774,12 +780,15 @@ func (m *agentDashboardModel) resetAddTunnelForm() {
780
&m.addTarget,
781
&m.addHTTPRoutes,
782
&m.addX402PayTo,
783
+ &m.addX402Testnet,
784
&m.addRelays,
785
} {
786
input.Reset()
787
}
788
+ m.addX402Testnet.SetValue("false")
789
m.addDiscovery.SetValue("true")
790
m.addMaxRelays.SetValue("3")
791
+ m.addX402Testnet.CursorEnd()
792
m.addDiscovery.CursorEnd()
793
m.addMaxRelays.CursorEnd()
794
m.addFocus = agentDashboardAddFieldName
@@ -893,6 +902,7 @@ func (m *agentDashboardModel) resizeInputs(width int) {
902
&m.addTarget,
903
&m.addHTTPRoutes,
904
&m.addX402PayTo,
905
+ &m.addX402Testnet,
906
&m.addRelays,
907
&m.addDiscovery,
908
&m.addMaxRelays,
@@ -996,6 +1006,17 @@ func (m agentDashboardModel) addTunnelRequest() (types.AgentTunnelRequest, error
1006
if len(routes) == 0 && payTo != "" {
1007
return types.AgentTunnelRequest{}, fmt.Errorf("X402 Pay To requires routes")
1008
}
1009
+ x402TestnetRaw := strings.TrimSpace(m.addX402Testnet.Value())
1010
+ if x402TestnetRaw == "" {
1011
+ x402TestnetRaw = "false"
1012
+ }
1013
+ x402Testnet, err := strconv.ParseBool(x402TestnetRaw)
1014
+ if err != nil {
1015
+ return types.AgentTunnelRequest{}, fmt.Errorf("X402 Testnet must be true or false")
1016
+ }
1017
+ if x402Testnet && !hasPaidRoute {
1018
+ return types.AgentTunnelRequest{}, fmt.Errorf("X402 Testnet requires paid routes")
1019
+ }
1020
1021
discoveryRaw := strings.TrimSpace(m.addDiscovery.Value())
1022
if discoveryRaw == "" {
@@ -1023,6 +1044,7 @@ func (m agentDashboardModel) addTunnelRequest() (types.AgentTunnelRequest, error
1044
Discovery: &discovery,
1045
MaxActiveRelays: maxRelays,
1046
X402PayTo: payTo,
1047
+ X402Testnet: x402Testnet,
1048
}, nil
1049
}
1050
@@ -1469,6 +1491,7 @@ func (m agentDashboardModel) renderAddTunnelForm(pane *agentDashboardView, width
1491
{label: "Target", input: m.addTarget, field: agentDashboardAddFieldTarget},
1492
{label: "Routes", input: m.addHTTPRoutes, field: agentDashboardAddFieldHTTPRoutes},
1493
{label: "X402 Pay To", input: m.addX402PayTo, field: agentDashboardAddFieldX402PayTo},
1494
+ {label: "X402 Testnet", input: m.addX402Testnet, field: agentDashboardAddFieldX402Testnet},
1495
{label: "Relays", input: m.addRelays, field: agentDashboardAddFieldRelays},
1496
{label: "Discovery", input: m.addDiscovery, field: agentDashboardAddFieldDiscovery},
1497
{label: "Max Relays", input: m.addMaxRelays, field: agentDashboardAddFieldMaxRelays},
@@ -1616,6 +1639,12 @@ func (m agentDashboardModel) renderSettingsInputRows(pane *agentDashboardView, w
1639
}
1640
pane.addMeta(width, 0, "Pay To", payTo)
1641
}
1642
+ if payTo != "" || paidRouteCount > 0 {
1643
+ if len(pane.lines)-startLine >= height {
1644
+ return
1645
+ }
1646
+ pane.addMeta(width, 0, "Network", agentDashboardX402Network(tunnel.X402Testnet))
1647
+ }
1648
if len(tunnel.HTTPRoutes) == 0 {
1649
if len(pane.lines)-startLine >= height {
1650
return
@@ -2025,6 +2054,13 @@ func agentDashboardHTTPRouteSummary(route types.AgentHTTPRoute) string {
2054
return prefix + " -> " + upstream
2055
}
2056
2057
+func agentDashboardX402Network(testnet bool) string {
2058
+ if testnet {
2059
+ return "sui:testnet"
2060
+ }
2061
+ return "sui:mainnet"
2062
+}
2063
+
2064
func (m agentDashboardModel) settingsChanged(tunnel types.AgentTunnelStatus) bool {
2065
if m.settingsEditTunnelID != tunnel.ID {
2066
return false
cmd/portal-tunnel/agent/manager.go
+3
@@ -258,6 +258,7 @@ func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
258
Discovery: &discovery,
259
MaxActiveRelays: req.MaxActiveRelays,
260
X402PayTo: strings.TrimSpace(req.X402PayTo),
261
+ X402Testnet: req.X402Testnet,
262
}
263
if slices.ContainsFunc(cfg.Tunnels, func(tunnel TunnelConfig) bool { return tunnel.ID == tunnelCfg.ID }) {
264
return fmt.Errorf("tunnel %q already exists", tunnelCfg.ID)
@@ -613,6 +614,7 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
614
Metadata: metadataFromTunnelConfig(cfg),
615
MultiHop: append([]string(nil), cfg.MultiHop...),
616
X402PayTo: strings.TrimSpace(cfg.X402PayTo),
617
+ X402Testnet: cfg.X402Testnet,
618
}
619
if len(cfg.HTTPRoutes) > 0 {
620
status.HTTPRoutes = make([]types.AgentHTTPRoute, 0, len(cfg.HTTPRoutes))
@@ -711,6 +713,7 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
713
MaxActiveRelays: cfg.MaxActiveRelays,
714
Metadata: metadataFromTunnelConfig(cfg),
715
X402PayTo: cfg.X402PayTo,
716
+ X402Testnet: cfg.X402Testnet,
717
})
718
if err != nil {
719
return err
cmd/portal-tunnel/main.go
+4
-1
@@ -60,6 +60,7 @@ type exposeFlags struct {
60
thumbnail string
61
hide bool
62
x402PayTo string
63
+ x402Testnet bool
64
targetAddr string
65
httpRoutes []string
66
udp bool
@@ -89,6 +90,7 @@ func runExposeCommand(args []string) error {
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 USDC payment recipient address for this tunnel")
93
+ utils.BoolFlag(fs, &flags.x402Testnet, "x402-testnet", false, "Use Sui testnet for tunnel x402 payments; default is Sui mainnet")
94
utils.RepeatedStringFlag(fs, &flags.httpRoutes, "http-route", "HTTP route mapping in PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT] 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")
@@ -202,7 +204,8 @@ func runExposeCommand(args []string) error {
204
Thumbnail: flags.thumbnail,
205
Hide: flags.hide,
206
},
205
- X402PayTo: flags.x402PayTo,
207
+ X402PayTo: flags.x402PayTo,
208
+ X402Testnet: flags.x402Testnet,
209
})
210
if err != nil {
211
return fmt.Errorf("failed to start relays: %w", err)
cmd/relay-server/main.go
+4
-4
@@ -93,9 +93,9 @@ func runServeCommand(args []string) error {
93
utils.StringFlagEnv(fs, &cfg.AdminToken, "admin-token", "", "admin bearer token for relay admin and policy APIs", "ADMIN_TOKEN")
94
utils.BoolFlagEnv(fs, &cfg.PProfEnabled, "pprof-enabled", false, "enable pprof diagnostics HTTP server", "PPROF_ENABLED")
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")
96
+ utils.BoolFlagEnv(fs, &cfg.X402Enabled, "x402-enabled", false, "enable relay-owned Sui x402 facilitator endpoints under /api/x402 for future control-plane payments", "X402_ENABLED")
97
+ utils.BoolFlagEnv(fs, &cfg.X402Testnet, "x402-testnet", false, "use Sui testnet for relay-owned x402 facilitator payments", "X402_TESTNET")
98
+ utils.StringFlagEnv(fs, &cfg.X402PayTo, "x402-pay-to", "", "Sui payment recipient address for relay-owned control-plane 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")
@@ -213,7 +213,7 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
213
log.Info().
214
Str("path", types.PathX402Facilitator).
215
Str("network", x402Network).
216
- Msg("embedded x402 facilitator enabled")
216
+ Msg("relay-owned x402 facilitator enabled")
217
}
218
219
if err := server.Start(ctx, apiMux); err != nil {
docs/src/routes/api-reference/+page.md
+17
-9
@@ -40,7 +40,7 @@ The envelope does not apply to streaming or delegated endpoints:
40
|------|--------|
41
| `/sdk/connect` | HTTP/1.1 connection hijack |
42
| `/v1/sign` | keyless TLS signer protocol |
43
-| `/api/x402/*` | embedded x402 facilitator response |
43
+| `/api/x402/*` | relay-owned x402 facilitator response |
44
| `/api/install.sh`, `/api/install.ps1`, `/api/install/bin/*` | script or binary bytes |
45
46
Unknown routes may be handled by the frontend/proxy layer or return a normal
@@ -114,18 +114,26 @@ SDK clients.
114
115
### Payments
116
117
-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
-Portal accepts only USDC gasless stablecoin address-balance payments.
122
-`X402_PAY_TO` is the relay-owned payment recipient. Tunnel payment recipients
123
-are local tunnel configuration and are not part of the relay lease API.
117
+Relay `/api/x402/*` endpoints are optional relay-owned control-plane
118
+facilitator endpoints. Enable them with `X402_ENABLED=true` when a relay
119
+operator wants to reserve support for relay resources such as future tunnel
120
+registration fees, lease renewal fees, raw TCP/UDP port allocation, or premium
121
+capacity. They are served by the embedded `gosuda/x402-facilitator` handler and
122
+do not use the Portal JSON envelope. Portal selects Sui mainnet by default and
123
+Sui testnet when `X402_TESTNET=true`. Portal accepts only USDC gasless
124
+stablecoin address-balance payments. `X402_PAY_TO` is the relay-owned payment
125
+recipient.
126
+
127
+Relay x402 settings do not affect tunnel paid routes. Tunnel payment recipients
128
+and payment networks are local tunnel configuration and are not part of the
129
+relay lease API.
130
131
Paid routed HTTP tunnels additionally expose `/x402/prepare` and
132
`/x402/client.js` on the public tunnel origin. Those are tunnel-owned helper
133
endpoints for app frontends, not relay API routes, and they do not use the
128
-`/api` prefix. `/x402/client.js` is browser-only; native clients call
134
+`/api` prefix. Tunnel paid routes use Sui mainnet by default and Sui testnet
135
+when the tunnel is exposed with `--x402-testnet` or configured with
136
+`x402_testnet = true`. `/x402/client.js` is browser-only; native clients call
137
`/x402/prepare` directly and send `X-PAYMENT` on the protected request.
138
139
| Method | Path | Auth | Body | Response |
docs/src/routes/api-reference/sdk/+page.md
+6
-2
@@ -12,7 +12,7 @@ that switches to a raw stream after a successful HTTP/1.1 response.
12
13
## Flow
14
15
-1. `GET /sdk/domain` checks relay compatibility, optional ENS support, and optional Sui x402 facilitator support.
15
+1. `GET /sdk/domain` checks relay compatibility, optional ENS support, and optional relay-owned Sui x402 control-plane facilitator support.
16
2. `POST /sdk/register/challenge` creates a SIWE challenge for the requested identity.
17
3. The SDK signs the returned `siwe_message`.
18
4. `POST /sdk/register` exchanges the signature for a lease `access_token`.
@@ -39,7 +39,7 @@ that switches to a raw stream after a successful HTTP/1.1 response.
39
| `protocol_version` | `string` | SDK tunnel protocol version |
40
| `release_version` | `string` | relay software release |
41
| `ens` | `ENSStatus` | gasless ENS status |
42
-| `x402` | `X402FacilitatorInfo` | embedded Sui x402 facilitator status |
42
+| `x402` | `X402FacilitatorInfo` | relay-owned Sui x402 control-plane facilitator status |
43
44
`ENSStatus`:
45
@@ -55,6 +55,10 @@ that switches to a raw stream after a successful HTTP/1.1 response.
55
| `enabled` | `boolean` |
56
| `url`, `network`, `network_name`, `supported_url`, `pay_to` | `string` |
57
58
+This object describes the relay's own optional x402 facilitator for
59
+control-plane resources. It is separate from tunnel-owned routed HTTP payments,
60
+which are configured locally by the tunnel process.
61
+
62
## Register Challenge
63
64
`RegisterChallengeRequest`:
docs/src/routes/cli-reference/+page.md
+6
-1
@@ -100,6 +100,7 @@ not supported.
100
| `--owner` | string | | Service owner metadata |
101
| `--hide` | bool | `false` | Hide service from relay listing screens |
102
| `--x402-pay-to` | string | | Sui USDC payment recipient address for this tunnel |
103
+| `--x402-testnet` | bool | `false` | Use Sui testnet for tunnel x402 payments; default is Sui mainnet |
104
| `--http-route` | string | | HTTP route mapping in `PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]` form; repeatable; route amounts require `--x402-pay-to` |
105
| `--tcp` | bool | `false` | Request a dedicated raw TCP port on the relay |
106
| `--udp` | bool | `false` | Enable public UDP relay in addition to the default stream path |
@@ -115,6 +116,8 @@ not supported.
116
- `--tcp` and `--udp` require matching transport support on the relay.
117
- Route payment amounts are part of `--http-route` and require a tunnel-owned
118
`--x402-pay-to`.
119
+- Tunnel paid routes use Sui mainnet by default; add `--x402-testnet` for Sui
120
+ testnet. This is independent of relay-owned x402 facilitator settings.
121
122
### Examples
123
@@ -216,7 +219,9 @@ const response = await x402Fetch('/paid/photo', { method: 'GET' }, {
219
transaction, asks the wallet to sign it, then retries the protected request with
220
an `X-PAYMENT` header. `onEvent` receives structured progress events; the older
221
`onStatus(message)` callback is still accepted for simple UIs. Routed HTTP
219
-payments currently use Sui mainnet, so omit `network` or pass `sui:mainnet`.
222
+payments use Sui mainnet by default; pass `--x402-testnet` when exposing the
223
+tunnel and use `network: 'sui:testnet'` in wallet clients that need an explicit
224
+network. For mainnet, omit `network` or pass `sui:mainnet`.
225
226
Native clients should not load `/x402/client.js`. Call `POST /x402/prepare` with
227
`{ "sender": "...", "method": "GET", "path": "/paid/photo" }`, execute
docs/src/routes/concepts/+page.md
+2
-1
@@ -101,7 +101,8 @@ origin. A browser frontend mounted through the tunnel can import
101
stays in the app instead of requiring a separate payment redirect. Native
102
clients use `/x402/prepare` directly and send the signed payload as
103
`X-PAYMENT`. The tunnel still verifies and settles the payment before proxying
104
-the protected request.
104
+the protected request. Paid routes use Sui mainnet by default; add
105
+`--x402-testnet` for Sui testnet.
106
107
## Dedicated Raw TCP
108
docs/src/routes/configuration/+page.md
+8
-4
@@ -41,9 +41,9 @@ The relay server (`relay-server`) reads configuration from environment variables
41
42
| Variable | Default | Type | Description |
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 |
44
+| `X402_ENABLED` | `false` | bool | Enable relay-owned Sui x402 facilitator endpoints under `/api/x402` for future control-plane payments |
45
+| `X402_TESTNET` | `false` | bool | Use Sui testnet for relay-owned x402 facilitator payments; `false` uses Sui mainnet |
46
+| `X402_PAY_TO` | `""` | string | Sui payment recipient address for relay-owned control-plane x402 resources |
47
48
### Proxy
49
@@ -163,6 +163,7 @@ The `portal expose` subcommand accepts the following flags. Flags that read from
163
| `--thumbnail` | | string | | Service thumbnail URL metadata |
164
| `--hide` | | bool | `false` | Hide service from relay listing screens |
165
| `--x402-pay-to` | | string | | Sui USDC payment recipient address for this tunnel |
166
+| `--x402-testnet` | | bool | `false` | Use Sui testnet for tunnel x402 payments; default is Sui mainnet |
167
168
### Routing
169
@@ -221,6 +222,7 @@ tags = ["web"]
222
id = "api"
223
name = "myapp"
224
x402_pay_to = "0x..."
225
+x402_testnet = true
226
227
[[tunnels.http_routes]]
228
prefix = "/api"
@@ -262,6 +264,7 @@ Tunnel fields mirror `portal expose` flags:
264
| `udp`, `udp_addr`, `tcp` | bool/string | UDP and raw TCP relay options |
265
| `description`, `tags`, `owner`, `thumbnail`, `hide` | mixed | Lease metadata shown by relays |
266
| `x402_pay_to` | string | Tunnel-owned Sui USDC x402 payment recipient for paid HTTP routes |
267
+| `x402_testnet` | bool | Use Sui testnet for tunnel-owned x402 paid routes; omitted or `false` uses Sui mainnet |
268
| `http_routes[].amount` | string | Optional Sui USDC x402 amount, such as `0.01`, for one HTTP route prefix; requires `x402_pay_to` |
269
| `http_routes[].methods` | string array | Optional HTTP methods that require payment on that route; empty means every method |
270
@@ -271,7 +274,8 @@ frontends served by another route in the same tunnel can import
274
`/x402/client.js` and use `x402Fetch()` to run the same Sui wallet payment flow
275
as the standalone payment app. Native clients use `/x402/prepare` directly and
276
send the signed payload as `X-PAYMENT`. Payment is still enforced by the tunnel
274
-on the paid route prefix.
277
+on the paid route prefix. Tunnel paid routes default to Sui mainnet and use Sui
278
+testnet when `x402_testnet = true`.
279
280
For a task-oriented walkthrough, see [Portal Agent](/portal-agent).
281
docs/src/routes/portal-agent/+page.md
+12
-7
@@ -71,6 +71,7 @@ name = "myapp"
71
relays = ["https://portal.example.com"]
72
discovery = false
73
x402_pay_to = "0x..."
74
+x402_testnet = true
75
76
[[tunnels.http_routes]]
77
prefix = "/api"
@@ -88,7 +89,8 @@ If a route has `amount`, the tunnel serves `/x402/client.js` and
89
`/` route can import the helper and call `x402Fetch()` from its own UI. Native
90
clients use `/x402/prepare` directly and send the signed payload as
91
`X-PAYMENT`. The tunnel still verifies and settles payment before proxying the
91
-paid route.
92
+paid route. Paid routes use Sui mainnet by default; set `x402_testnet = true`
93
+to use Sui testnet.
94
95
Relative paths in the config are resolved from the config file directory.
96
@@ -163,15 +165,17 @@ tunnel or `Routes` for routed HTTP. Routes use this syntax:
165
/paid=3001 GET:0.01; /=5173
166
```
167
166
-Each entry is `PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]`. Fill `X402 Pay To`
167
-when any route has an amount. The form also accepts explicit `Relays`,
168
+Each entry is `PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]`. Fill `X402 Pay
169
+To` when any route has an amount, and set `X402 Testnet` to `true` for Sui
170
+testnet. The form also accepts explicit `Relays`,
171
`Discovery`, and `Max Relays`; max relays caps auto-selected discovery relays
172
while explicit relays are still included.
173
171
-After creation, routed HTTP paths, x402 payment amounts, and discovery mode are
172
-read-only in the Settings pane. To change routes, payment amounts, or discovery
173
-mode, edit `http_routes`, `x402_pay_to`, and `discovery` in `config.toml`, then
174
-restart the agent or tunnel. Other advanced options such as UDP, TCP, custom
174
+After creation, routed HTTP paths, x402 payment amounts, payment network, and
175
+discovery mode are read-only in the Settings pane. To change routes, payment
176
+amounts, payment network, or discovery mode, edit `http_routes`,
177
+`x402_pay_to`, `x402_testnet`, and `discovery` in `config.toml`, then restart
178
+the agent or tunnel. Other advanced options such as UDP, TCP, custom
179
identity JSON, or explicit multi-hop defaults are also configured in
180
`config.toml`.
181
@@ -197,6 +201,7 @@ Common fields:
201
| `ban_mitm` | Ban relays when the TLS self-probe detects termination; defaults to warning-only |
202
| `description`, `tags`, `owner`, `thumbnail`, `hide` | Public relay metadata |
203
| `x402_pay_to` | Tunnel-owned Sui USDC x402 recipient for paid HTTP routes |
204
+| `x402_testnet` | Use Sui testnet for tunnel-owned x402 paid routes; omitted or `false` uses Sui mainnet |
205
| `http_routes[].amount` | Optional Sui USDC x402 amount, such as `0.01`, for one HTTP route prefix |
206
| `http_routes[].methods` | Optional HTTP methods that require payment on that route; empty means every method |
207
docs/src/routes/self-hosting/+page.md
+13
-7
@@ -85,11 +85,13 @@ docker compose up -d
85
| `IDENTITY_PATH` | `./.portal-certs` | Relay state directory containing `identity.json`, `policy.json`, and TLS materials. |
86
| `ADMIN_TOKEN` | | Bearer token source for relay admin and policy APIs. |
87
88
-## Optional: Enable Embedded Sui x402 Facilitator
88
+## Optional: Enable Relay-Owned Sui x402 Facilitator
89
90
-To expose Sui x402 facilitator endpoints from the relay process itself, enable
91
-the embedded handler. Payments use Sui mainnet by default; set
92
-`X402_TESTNET=true` for Sui testnet.
90
+To reserve relay-side x402 support for future control-plane resources, enable
91
+the relay-owned facilitator. This is intended for relay-owned charges such as
92
+tunnel registration, lease renewal, raw TCP/UDP port allocation, or premium
93
+capacity if an operator decides to require them. Payments use Sui mainnet by
94
+default; set `X402_TESTNET=true` for Sui testnet.
95
96
```yaml
97
environment:
@@ -100,9 +102,13 @@ environment:
102
103
This serves `/api/x402/supported`, `/api/x402/verify`, and `/api/x402/settle`.
104
Portal payments intentionally support only Sui mainnet/testnet USDC through the
103
-gasless stablecoin address-balance flow. Route-level payment enforcement is
104
-configured separately by the tunnel with `portal expose --x402-pay-to`; relay
105
-`X402_PAY_TO` is reserved for relay-owned resources.
105
+gasless stablecoin address-balance flow.
106
+
107
+Tunnel paid routes do not use these relay settings. Route-level payment
108
+enforcement is configured separately by the tunnel with
109
+`portal expose --x402-pay-to` and optional `--x402-testnet`; relay
110
+`X402_PAY_TO` and `X402_TESTNET` are reserved for relay-owned control-plane
111
+resources.
112
113
## Connecting Your Tunnel
114
frontend/src/components/Header.tsx
+1
-1
@@ -132,7 +132,7 @@ export function Header({
132
)}
133
{x402 && (
134
<span className="inline-flex h-6 items-center rounded-full bg-secondary px-2.5 text-xs font-semibold text-text-muted ring-1 ring-border/70">
135
- x402 {x402NetworkLabel(x402)}
135
+ relay x402 {x402NetworkLabel(x402)}
136
</span>
137
)}
138
</div>
sdk/expose.go
+3
-1
@@ -57,6 +57,7 @@ type ExposeConfig struct {
57
MaxActiveRelays int
58
Metadata types.LeaseMetadata
59
X402PayTo string
60
+ X402Testnet bool
61
}
62
63
func (cfg ExposeConfig) snapshot() ExposeConfig {
@@ -153,6 +154,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
154
runtimeCfg.MultiHop = append([]string(nil), multiHop...)
155
runtimeCfg.Metadata = cfg.Metadata.Copy()
156
runtimeCfg.X402PayTo = x402PayTo
157
+ runtimeCfg.X402Testnet = cfg.X402Testnet
158
159
exposureCtx, cancel := context.WithCancel(ctx)
160
exposure := &Exposure{
@@ -525,7 +527,7 @@ func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
527
// RunHTTPRoutes serves path-routed HTTP upstreams through the exposure.
528
func (e *Exposure) RunHTTPRoutes(ctx context.Context, routes []HTTPRouteConfig, localAddr string) error {
529
cfg := e.Config()
528
- handler, err := NewHTTPRoutes(routes, cfg.X402PayTo)
530
+ handler, err := NewHTTPRoutes(routes, cfg.X402PayTo, cfg.X402Testnet)
531
if err != nil {
532
return err
533
}
sdk/http.go
+6
-5
@@ -142,7 +142,7 @@ type HTTPRoutes struct {
142
}
143
144
// NewHTTPRoutes creates a handler for path-routed upstreams and the shared x402 prepare endpoint.
145
-func NewHTTPRoutes(routeConfigs []HTTPRouteConfig, x402PayTo string) (*HTTPRoutes, error) {
145
+func NewHTTPRoutes(routeConfigs []HTTPRouteConfig, x402PayTo string, x402Testnet bool) (*HTTPRoutes, error) {
146
if len(routeConfigs) == 0 {
147
return nil, errors.New("at least one http route is required")
148
}
@@ -151,7 +151,7 @@ func NewHTTPRoutes(routeConfigs []HTTPRouteConfig, x402PayTo string) (*HTTPRoute
151
routes := make([]*httpRoute, 0, len(routeConfigs))
152
seen := make(map[string]struct{}, len(routeConfigs))
153
for _, routeConfig := range routeConfigs {
154
- route, err := newHTTPRoute(routeConfig, x402PayTo)
154
+ route, err := newHTTPRoute(routeConfig, x402PayTo, x402Testnet)
155
if err != nil {
156
return nil, err
157
}
@@ -240,7 +240,7 @@ type httpRoute struct {
240
handler http.Handler
241
}
242
243
-func newHTTPRoute(routeConfig HTTPRouteConfig, x402PayTo string) (*httpRoute, error) {
243
+func newHTTPRoute(routeConfig HTTPRouteConfig, x402PayTo string, x402Testnet bool) (*httpRoute, error) {
244
prefix := strings.TrimSpace(routeConfig.Prefix)
245
if prefix == "" {
246
return nil, errors.New("http route prefix is required")
@@ -298,8 +298,9 @@ func newHTTPRoute(routeConfig HTTPRouteConfig, x402PayTo string) (*httpRoute, er
298
methods[method] = struct{}{}
299
}
300
payment, err := x402.NewUSDCPayment(types.X402Payment{
301
- PayTo: x402PayTo,
302
- Amount: amount,
301
+ Testnet: x402Testnet,
302
+ PayTo: x402PayTo,
303
+ Amount: amount,
304
})
305
if err != nil {
306
return nil, fmt.Errorf("http route %q x402 payment: %w", route.prefix, err)
sdk/http_test.go
+3
-3
@@ -25,7 +25,7 @@ func TestHTTPRoutesUseLongestPrefix(t *testing.T) {
25
handler, err := NewHTTPRoutes([]HTTPRouteConfig{
26
{Prefix: "/", Upstream: rootServer.URL},
27
{Prefix: "/api", Upstream: apiServer.URL},
28
- }, "")
28
+ }, "", false)
29
if err != nil {
30
t.Fatalf("NewHTTPRoutes() error = %v", err)
31
}
@@ -56,7 +56,7 @@ func TestHTTPRoutesRewriteResponseHeaders(t *testing.T) {
56
57
handler, err := NewHTTPRoutes([]HTTPRouteConfig{
58
{Prefix: "/app", Upstream: upstreamURL + "/base"},
59
- }, "")
59
+ }, "", false)
60
if err != nil {
61
t.Fatalf("NewHTTPRoutes() error = %v", err)
62
}
@@ -80,7 +80,7 @@ func TestHTTPRoutesRejectDuplicateNormalizedPrefixes(t *testing.T) {
80
_, err := NewHTTPRoutes([]HTTPRouteConfig{
81
{Prefix: "/api", Upstream: "127.0.0.1:3001"},
82
{Prefix: "/api/", Upstream: "127.0.0.1:3002"},
83
- }, "")
83
+ }, "", false)
84
if err == nil {
85
t.Fatal("NewHTTPRoutes() error = nil, want duplicate prefix error")
86
}
types/agent.go
+2
@@ -19,6 +19,7 @@ type AgentTunnelStatus struct {
19
Metadata LeaseMetadata `json:"metadata,omitempty"`
20
MultiHop []string `json:"multi_hop,omitempty"`
21
X402PayTo string `json:"x402_pay_to,omitempty"`
22
+ X402Testnet bool `json:"x402_testnet,omitempty"`
23
HTTPRoutes []AgentHTTPRoute `json:"http_routes,omitempty"`
24
Relays []AgentRelayStatus `json:"relays,omitempty"`
25
}
@@ -52,6 +53,7 @@ type AgentTunnelRequest struct {
53
Discovery *bool `json:"discovery,omitempty"`
54
MaxActiveRelays int `json:"max_active_relays,omitempty"`
55
X402PayTo string `json:"x402_pay_to,omitempty"`
56
+ X402Testnet bool `json:"x402_testnet,omitempty"`
57
}
58
59
type AgentRelayRequest struct {
types/x402.go
+1
-1
@@ -7,7 +7,7 @@ import (
7
facilitatortypes "github.com/gosuda/x402-facilitator/types"
8
)
9
10
-// X402FacilitatorInfo describes relay-level x402 facilitator settings exposed by the API.
10
+// X402FacilitatorInfo describes relay-owned x402 control-plane facilitator settings exposed by the API.
11
type X402FacilitatorInfo struct {
12
Enabled bool `json:"enabled"`
13
URL string `json:"url,omitempty"`