feat: add x402 facilitator support and configuration

- Introduced environment variables for x402 facilitator configuration in .env.example and docker-compose.yml. - Updated main.go to include x402 facilitator settings in the server configuration. - Enhanced API responses to include x402 facilitator status and metadata. - Added documentation for x402 facilitator in SDK API and Configuration Reference. - Implemented x402 facilitator handling in the server logic and tests. - Removed unused dependency on StackExchange/wmi from go.mod.

Kim committed May 27, 2026 at 10:36 UTC d59b963280a0c08b7d4b75c2c1930a52be7f3b6f
15 files changed +140 -27
.env.example
+4 -1
@@ -14,7 +14,6 @@ MAX_PORT=0
14 UDP_ENABLED=false
15 TCP_ENABLED=false
16
17 -
17 # Supported managed values: cloudflare, gcloud, hetzner, route53, vultr.
18 # Reused for ACME DNS-01, managed A records, ECH HTTPS records, and optional ENS DNS automation.
19 ACME_DNS_PROVIDER=
@@ -58,3 +57,7 @@ TRUSTED_PROXY_CIDRS=
57 # Requires the headless-shell sidecar (chromedp/headless-shell) in docker-compose.
58 # Leave empty or remove to disable. See docs/src/routes/deployment/+page.md.
59 # HEADLESS_SHELL_URL=ws://headless-shell:9222
60 +
61 +X402_FACILITATOR_ENABLED=false
62 +X402_NETWORK=eip155:84532
63 +X402_RPC_URL=https://base-sepolia-rpc.publicnode.com
\ No newline at end of file
cmd/relay-server/main.go
+2
@@ -177,6 +177,8 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
177 MaxPort: cfg.MaxPort,
178 PProfEnabled: cfg.PProfEnabled,
179 PProfListenAddr: cfg.PProfAddr,
180 + X402Enabled: cfg.X402Enabled,
181 + X402Network: cfg.X402Network,
182 ACME: acme.Config{
183 KeyDir: cfg.IdentityPath,
184 DNSProvider: cfg.ACMEDNSProvider,
docker-compose.yml
+5
@@ -46,6 +46,11 @@ services:
46 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
47 TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
48
49 + # Optional: relay-local x402 facilitator exposed under /x402
50 + X402_FACILITATOR_ENABLED: ${X402_FACILITATOR_ENABLED:-false}
51 + X402_NETWORK: ${X402_NETWORK:-eip155:84532}
52 + X402_RPC_URL: ${X402_RPC_URL:-https://base-sepolia-rpc.publicnode.com}
53 +
54 # Optional: auto-generated thumbnails (requires headless-shell sidecar above)
55 # HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
56
docs/src/routes/api-reference/sdk/+page.md
+18
@@ -67,6 +67,7 @@ Get relay domain and protocol version information. Used by the SDK to verify rel
67 | `protocol_version` | `string` | Protocol version (must match SDK version) |
68 | `release_version` | `string` | Relay software release version |
69 | `ens` | `object` | ENS gasless status for this relay |
70 +| `x402` | `object` | Relay-local x402 facilitator status and public payment metadata |
71
72 `ens` fields:
73
@@ -81,6 +82,16 @@ Get relay domain and protocol version information. Used by the SDK to verify rel
82 | `message` | `string` | Provider-specific DNSSEC guidance |
83 | `last_error` | `string` | Last ENS/DNS sync error |
84
85 +`x402` fields:
86 +
87 +| Field | Type | Description |
88 +|-------|------|-------------|
89 +| `enabled` | `bool` | Relay-local facilitator is exposed under `/x402` |
90 +| `url` | `string` | Facilitator base URL to use in tunnel x402 config |
91 +| `network` | `string` | CAIP-2 payment network served by the facilitator |
92 +| `network_name` | `string` | Human-readable network name when known |
93 +| `supported_url` | `string` | URL for the facilitator `/supported` endpoint |
94 +
95 **Example:**
96
97 ```bash
@@ -101,6 +112,13 @@ curl https://relay.example.com/sdk/domain
112 "provider": "cloudflare",
113 "address": "0x1234567890abcdef1234567890abcdef12345678",
114 "dnssec_state": "active"
115 + },
116 + "x402": {
117 + "enabled": true,
118 + "url": "https://relay.example.com/x402",
119 + "network": "eip155:84532",
120 + "network_name": "Base Sepolia",
121 + "supported_url": "https://relay.example.com/x402/supported"
122 }
123 }
124 }
docs/src/routes/configuration/+page.md
+2 -1
@@ -74,7 +74,8 @@ The relay server (`relay-server`) reads configuration from environment variables
74 | `X402_RPC_URL` | | string | RPC URL used by the facilitator; empty uses the facilitator default for supported networks |
75
76 The relay-local facilitator uses the relay identity private key from
77 -`IDENTITY_PATH/identity.json`.
77 +`IDENTITY_PATH/identity.json`. `/sdk/domain` exposes only the public facilitator
78 +URL and network; `X402_RPC_URL` is not returned to clients.
79
80 ### Cloudflare
81
docs/src/routes/self-hosting/+page.md
+2 -2
@@ -135,8 +135,8 @@ See [TCP/UDP Tunneling](/tcp-udp-tunneling) for usage details.
135 ## Optional: Enable x402 Facilitator
136
137 The relay can expose a relay-local x402 facilitator at `/x402`. Frontends and
138 -configuration tools can read `/x402/supported` and write the selected
139 -facilitator URL into tunnel x402 route config.
138 +configuration tools can read `/sdk/domain` for the current relay's facilitator
139 +URL and network, then call `/x402/supported` for mechanism details when needed.
140
141 ```yaml
142 environment:
frontend/src/components/Header.tsx
+31
@@ -24,6 +24,15 @@ interface DomainStatusResponse {
24 ens?: {
25 verified?: boolean;
26 };
27 + x402?: X402FacilitatorInfo;
28 +}
29 +
30 +interface X402FacilitatorInfo {
31 + enabled?: boolean;
32 + url?: string;
33 + network?: string;
34 + network_name?: string;
35 + supported_url?: string;
36 }
37
38 const repoURL = "https://github.com/gosuda/portal-tunnel";
@@ -36,6 +45,14 @@ function formatWalletAddress(address: string): string {
45 return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;
46 }
47
48 +function facilitatorURL(x402: X402FacilitatorInfo): string {
49 + return x402.url?.trim() || "/x402";
50 +}
51 +
52 +function facilitatorNetworkLabel(x402: X402FacilitatorInfo): string {
53 + return x402.network_name?.trim() || x402.network?.trim() || "enabled";
54 +}
55 +
56 export function Header({
57 title = "PORTAL",
58 isAdmin,
@@ -44,6 +61,7 @@ export function Header({
61 }: HeaderProps) {
62 const releaseVersion = getReleaseVersion();
63 const [ensVerified, setENSVerified] = useState(false);
64 + const [x402, setX402] = useState<X402FacilitatorInfo | null>(null);
65 const {
66 isAuthenticated,
67 isLoading,
@@ -86,10 +104,12 @@ export function Header({
104 );
105 if (!cancelled) {
106 setENSVerified(status?.ens?.verified === true);
107 + setX402(status?.x402?.enabled === true ? status.x402 : null);
108 }
109 } catch {
110 if (!cancelled) {
111 setENSVerified(false);
112 + setX402(null);
113 }
114 }
115 })();
@@ -133,6 +153,17 @@ export function Header({
153 ENS verified
154 </span>
155 )}
156 + {x402 && (
157 + <a
158 + href={facilitatorURL(x402)}
159 + target="_blank"
160 + rel="noopener noreferrer"
161 + className="inline-flex h-6 max-w-40 items-center overflow-hidden text-ellipsis whitespace-nowrap rounded-full bg-emerald-500/10 px-2.5 text-xs font-semibold text-emerald-700 ring-1 ring-emerald-500/20 transition-colors hover:bg-emerald-500/15 dark:text-emerald-300"
162 + title={`x402 facilitator: ${facilitatorURL(x402)}`}
163 + >
164 + x402 {facilitatorNetworkLabel(x402)}
165 + </a>
166 + )}
167 </div>
168 </div>
169 </div>
go.mod
-1
@@ -50,7 +50,6 @@ require (
50 github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
51 github.com/PuerkitoBio/purell v1.1.1 // indirect
52 github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
53 - github.com/StackExchange/wmi v1.2.1 // indirect
53 github.com/atotto/clipboard v0.1.4 // indirect
54 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
55 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
go.sum
+19 -19
@@ -18,8 +18,6 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
18 github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
19 github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
20 github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
21 -github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
22 -github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
21 github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
22 github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
23 github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
@@ -66,6 +64,8 @@ github.com/blocto/solana-go-sdk v1.30.0 h1:GEh4GDjYk1lMhV/hqJDCyuDeCuc5dianbN33y
64 github.com/blocto/solana-go-sdk v1.30.0/go.mod h1:Xoyhhb3hrGpEQ5rJps5a3OgMwDpmEhrd9bgzFKkkwMs=
65 github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
66 github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
67 +github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk=
68 +github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s=
69 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
70 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
71 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
@@ -116,17 +116,14 @@ github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA=
116 github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc=
117 github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g=
118 github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY=
119 -github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
120 -github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
119 github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
120 github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
123 -github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
124 -github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
121 github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
126 -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4=
127 -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc=
122 +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
123 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
124 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
125 +github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiDR1gg0=
126 +github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M=
127 github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
128 github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
129 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -145,6 +142,8 @@ github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeD
142 github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
143 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
144 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
145 +github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI=
146 +github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww=
147 github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
148 github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
149 github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
@@ -158,7 +157,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
157 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
158 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
159 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
161 -github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
160 github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
161 github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
162 github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
@@ -214,6 +212,8 @@ github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb
212 github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc=
213 github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
214 github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
215 +github.com/graph-gophers/graphql-go v1.3.0 h1:Eb9x/q6MFpCLz7jBCiP/WTxjSDrYLR1QY41SORZyNJ0=
216 +github.com/graph-gophers/graphql-go v1.3.0/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc=
217 github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE=
218 github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0=
219 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
@@ -234,6 +234,12 @@ github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA
234 github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
235 github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
236 github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
237 +github.com/influxdata/influxdb-client-go/v2 v2.4.0 h1:HGBfZYStlx3Kqvsv1h2pJixbCl/jhnFtxpKFAv9Tu5k=
238 +github.com/influxdata/influxdb-client-go/v2 v2.4.0/go.mod h1:vLNHdxTJkIf2mSLvGrpj8TCcISApPoXkaxP8g9uRlW8=
239 +github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSHzRbhzK8RdXOsAdfDgO49TtqC1oZ+acxPrkfTxcCs=
240 +github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
241 +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU=
242 +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo=
243 github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
244 github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
245 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -308,8 +314,12 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3
314 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
315 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
316 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
317 +github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU=
318 +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
319 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
320 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
321 +github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7 h1:oYW+YCJ1pachXTQmzR3rNLYGGz4g/UgFcjb28p/viDM=
322 +github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0=
323 github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
324 github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
325 github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
@@ -348,8 +358,6 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
358 github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
359 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
360 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
351 -github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
352 -github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
361 github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
362 github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
363 github.com/spruceid/siwe-go v0.2.1 h1:BroySys6CyUzeyNppTseEOT/w56xTdOfcmECTI7rnuc=
@@ -433,8 +441,6 @@ go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
441 go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
442 go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
443 go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
436 -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
437 -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
444 golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
445 golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
446 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
@@ -442,8 +448,6 @@ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aI
448 golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
449 golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
450 golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
445 -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
446 -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
451 golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
452 golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
453 golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
@@ -460,15 +464,11 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
464 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
465 golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
466 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
463 -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
464 -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
467 golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
468 golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
469 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
470 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
471 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
470 -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
471 -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
472 golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
473 golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
474 golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
portal/api_server.go
+13
@@ -17,6 +17,7 @@ import (
17 "github.com/gosuda/portal-tunnel/v2/portal/auth"
18 "github.com/gosuda/portal-tunnel/v2/portal/identity"
19 "github.com/gosuda/portal-tunnel/v2/portal/keyless"
20 + portalx402 "github.com/gosuda/portal-tunnel/v2/portal/x402"
21 "github.com/gosuda/portal-tunnel/v2/types"
22 "github.com/gosuda/portal-tunnel/v2/utils"
23 )
@@ -248,10 +249,22 @@ func (s *Server) handleDomain(w http.ResponseWriter, r *http.Request) {
249 return
250 }
251
252 + cfg := s.config()
253 + x402 := types.X402FacilitatorInfo{
254 + Enabled: cfg.X402Enabled,
255 + }
256 + if x402.Enabled {
257 + x402.Network = strings.TrimSpace(cfg.X402Network)
258 + x402.NetworkName = portalx402.NetworkDisplayName(x402.Network)
259 + x402.URL = cfg.PortalURL + types.PathX402Facilitator
260 + x402.SupportedURL = cfg.PortalURL + types.X402SupportedPath
261 + }
262 +
263 utils.WriteAPIData(w, http.StatusOK, types.DomainResponse{
264 ProtocolVersion: types.SDKVersion,
265 ReleaseVersion: types.ReleaseVersion,
266 ENS: s.acmeManager.ENSStatus(),
267 + X402: x402,
268 })
269 }
270
portal/server.go
+3
@@ -56,6 +56,8 @@ type ServerConfig struct {
56 MaxPort int
57 PProfEnabled bool
58 PProfListenAddr string
59 + X402Enabled bool
60 + X402Network string
61 ACME acme.Config
62 }
63
@@ -90,6 +92,7 @@ func normalizeServerConfig(cfg ServerConfig) (ServerConfig, error) {
92 if cfg.PProfEnabled {
93 cfg.PProfListenAddr = utils.StringOrDefault(strings.TrimSpace(cfg.PProfListenAddr), DefaultPProfListenAddr)
94 }
95 + cfg.X402Network = strings.TrimSpace(cfg.X402Network)
96
97 hasPortRange := cfg.MinPort > 0 && cfg.MaxPort > 0
98 if cfg.UDPEnabled || cfg.TCPEnabled {
portal/server_test.go
+17
@@ -296,6 +296,8 @@ func TestServerStartDomainReportsCompatibilityInfo(t *testing.T) {
296 SNIPort: 4443,
297 APIListenAddr: "127.0.0.1:0",
298 SNIListenAddr: "127.0.0.1:0",
299 + X402Enabled: true,
300 + X402Network: " eip155:84532 ",
301 })
302 if err != nil {
303 t.Fatalf("NewServer() error = %v", err)
@@ -338,6 +340,21 @@ func TestServerStartDomainReportsCompatibilityInfo(t *testing.T) {
340 if envelope.Data.ReleaseVersion != types.ReleaseVersion {
341 t.Fatalf("DomainResponse.ReleaseVersion = %q, want %q", envelope.Data.ReleaseVersion, types.ReleaseVersion)
342 }
343 + if !envelope.Data.X402.Enabled {
344 + t.Fatal("DomainResponse.X402.Enabled = false, want true")
345 + }
346 + if envelope.Data.X402.Network != "eip155:84532" {
347 + t.Fatalf("DomainResponse.X402.Network = %q, want eip155:84532", envelope.Data.X402.Network)
348 + }
349 + if envelope.Data.X402.NetworkName != "Base Sepolia" {
350 + t.Fatalf("DomainResponse.X402.NetworkName = %q, want Base Sepolia", envelope.Data.X402.NetworkName)
351 + }
352 + if envelope.Data.X402.URL != "https://localhost:4017/x402" {
353 + t.Fatalf("DomainResponse.X402.URL = %q, want https://localhost:4017/x402", envelope.Data.X402.URL)
354 + }
355 + if envelope.Data.X402.SupportedURL != "https://localhost:4017/x402/supported" {
356 + t.Fatalf("DomainResponse.X402.SupportedURL = %q, want https://localhost:4017/x402/supported", envelope.Data.X402.SupportedURL)
357 + }
358 }
359
360 func TestRegisterLeaseIncludesSNIPortForPublicIngress(t *testing.T) {
portal/x402/x402.go
+12
@@ -25,6 +25,18 @@ const (
25 defaultRouteDescription = "Portal protected route"
26 )
27
28 +var networkDisplayNames = map[string]string{
29 + "eip155:1": "Ethereum Mainnet",
30 + "eip155:8453": "Base Mainnet",
31 + "eip155:84532": "Base Sepolia",
32 + "eip155:42161": "Arbitrum One",
33 + "eip155:421614": "Arbitrum Sepolia",
34 +}
35 +
36 +func NetworkDisplayName(network string) string {
37 + return networkDisplayNames[strings.TrimSpace(strings.ToLower(network))]
38 +}
39 +
40 type FacilitatorConfig struct {
41 Network string
42 RPCURL string
types/api.go
+4 -3
@@ -184,9 +184,10 @@ func HopRouteBytes(method string, route HopRoute) ([]byte, error) {
184 }
185
186 type DomainResponse struct {
187 - ProtocolVersion string `json:"protocol_version"`
188 - ReleaseVersion string `json:"release_version"`
189 - ENS ENSStatus `json:"ens"`
187 + ProtocolVersion string `json:"protocol_version"`
188 + ReleaseVersion string `json:"release_version"`
189 + ENS ENSStatus `json:"ens"`
190 + X402 X402FacilitatorInfo `json:"x402"`
191 }
192
193 type ENSStatus struct {
types/x402.go
+8
@@ -29,6 +29,14 @@ func (c X402Config) Empty() bool {
29 c.PaymentTimeoutSecs == 0
30 }
31
32 +type X402FacilitatorInfo struct {
33 + Enabled bool `json:"enabled"`
34 + URL string `json:"url,omitempty"`
35 + Network string `json:"network,omitempty"`
36 + NetworkName string `json:"network_name,omitempty"`
37 + SupportedURL string `json:"supported_url,omitempty"`
38 +}
39 +
40 type X402SupportedKind struct {
41 X402Version int `json:"x402Version"`
42 Scheme string `json:"scheme"`