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"`