Refactor README and documentation for x402 payment integration
- Updated README.md and README.zh-CN.md to clarify the purpose and features of Portal, emphasizing self-hosting and payment capabilities. - Enhanced the comparison table to include native x402 payments. - Added examples for x402 payment routes in the Quick Start section. - Improved documentation for x402 payment flow in API and CLI references, detailing the use of `/x402/client.js` and `/x402/prepare`. - Modified payment-app to allow dynamic recipient addresses for x402 payments. - Updated client.js to support new payment event handling and improved transaction preparation logic. - Adjusted Docker Compose configuration to ensure proper UDP port mapping.
rabbitprincess committed
Jun 6, 2026 at 13:22 UTC
28bc1db4a7184fb897f8db7975d269b47459308e
13 files changed
+352
-105
README.md
+76
-36
@@ -1,42 +1,60 @@
1
-# Portal - The Trustless Relay Network for Localhost
1
+# Portal - Self-Hostable Relay Tunnel for Localhost
2
3
[English](./README.md) | [简体中文](./README.zh-CN.md)
4
5
<p align="center"><img width="800" alt="Portal Demo" src="./portal.gif" /></p>
6
7
-<p align="center"><b>Expose local services to the public internet with zero trust in the relay operator.</b><br/>No port forwarding. No inbound firewall rules. No manual DNS setup. No surveillance.</p>
7
+<p align="center"><b>Expose local services through self-hosted or public relays.</b><br/>No port forwarding. No inbound firewall rules. No manual DNS setup. No accounts.</p>
8
9
-## Why Portal? The Trustless Advantage
9
+## Why Portal?
10
11
-Most tunneling services (ngrok, Cloudflare Tunnel) terminate your TLS connection at their edge. This means **they can read your plaintext traffic**. Portal is built on a fundamentally different model: **relays are blind by design**.
11
+Portal is a local tunnel runtime and relay network for publishing services to the agentic web.
12
+It publishes local apps, APIs, tools, and agents through self-hosted or public relays,
13
+keeps routing and x402 payment policy in the tunnel process, and avoids requiring a hosted vendor account.
14
13
-- **End-to-End Encryption with ECH** — Your HTTPS traffic stays encrypted through the relay, and ECH-capable clients avoid exposing the real hostname in plaintext SNI. Portal keeps TLS on your machine, so relay operators cannot read your web traffic or easily profile it by hostname.
15
+- **Self-Hostable, Fully Open Source** - Run your own relay with a single
16
+ command. The relay is MIT-licensed with no enterprise tier, no feature gating,
17
+ and no call-home. Your relay, your rules.
18
15
-- **Built-in MITM Detection** — Portal actively self-probes its own connection after real traffic begins. It compares TLS keying material exported on both the client and server sides. A mismatch is treated as suspected relay-side TLS termination and the relay is banned by default.
19
+- **Anonymous Relay Network** - Connect to public relays without a hosted
20
+ account or central operator. Combine self-hosted relays with public relays in
21
+ a pool to split trust across independent operators you choose.
22
17
-- **Self-Hostable, Fully Open Source** — Run your own relay with a single command. The relay is MIT-licensed with no enterprise tier, no feature gating, and no call-home. Your relay, your rules.
23
+- **End-to-End Tenant TLS And ECH** - Because relays are trustless, Portal
24
+ terminates tenant TLS at the user's endpoint instead of the relay. Portal also
25
+ provides ECH to avoid exposing the real hostname in plaintext SNI.
26
19
-- **Anonymous Relay Network** — Because relays are trustless, you can connect to any public relay in the registry without compromising your privacy. Combine self-hosted relays with public relays in a pool — or chain them in a multi-hop route — to split trust across independent operators you choose.
27
+- **Built-in MITM Detection** - Portal actively self-probes its own connection
28
+ after real traffic begins. It compares TLS keying material exported on both
29
+ sides and treats a mismatch as suspected relay-side TLS termination.
30
21
-- **Multi-Hop Relay Routing** — Chain multiple relays together (similar to Tor). No single relay knows both the origin and the destination of the traffic. Use `--multi-hop-depth 3` to select a three-hop route automatically.
31
+- **Multi-Hop Relay Routing** - Chain multiple relays together so no single
32
+ relay knows both the origin and the destination. Use `--multi-hop-depth 3` to
33
+ select a three-hop route automatically.
34
23
-- **No Accounts, No API Keys** — Authentication uses SIWE (Sign-In with Ethereum) with a locally generated secp256k1 key pair. No email, no registration, no vendor lock-in.
35
+- **No Accounts, No API Keys** - Authentication uses SIWE-compatible signing
36
+ with a locally generated secp256k1 key pair. No email, no registration, no
37
+ vendor lock-in.
38
+
39
+- **Built-in x402 Payments** - Routed HTTP paths can require Sui gasless
40
+ USDC x402 payment before proxying. Browser apps can import `/x402/client.js`,
41
+ and native clients can call `/x402/prepare` directly and send `X-PAYMENT`.
42
43
## Comparison
44
45
| | Portal | ngrok | Cloudflare Tunnel | frp |
46
|---|---|---|---|---|
47
+| Public localhost URL | **Yes** | Yes | Yes | Yes |
48
+| Self-hostable | **Yes** | Enterprise only | No | Yes |
49
+| Open source | **MIT** | No | Client only | Apache 2.0 |
50
+| Custom domain | **Yes** | Paid plans | Yes | Yes |
51
| End-to-end tenant TLS | **Yes** | No | No | No |
52
| SNI hiding (ECH) | **Yes** | No | No | No |
53
| MITM self-probe | **Built-in** | No | No | No |
32
-| Multi-hop routing | **Yes** | No | No | No |
54
| Multi-relay failover | **Yes** | Managed | Built-in | No |
34
-| Self-hostable | **Yes** | Enterprise only | No | Yes |
35
-| Custom domain | **Yes** | Paid plans | Yes | Yes |
36
-| Raw TCP port routing | **Yes** | Paid plans | No | Yes |
37
-| UDP routing | **Yes** | Yes | Yes | Yes |
38
-| Open source | **MIT** | No | Client only | Apache 2.0 |
55
+| Multi-hop routing | **Yes** | No | No | No |
56
| Account required | **No** | Yes | Yes | No |
57
+| Native x402 payments | **Yes** | No | No | No |
58
59
## Quick Start
60
@@ -68,6 +86,12 @@ portal expose --name myapp \
86
--http-route /api=http://127.0.0.1:3001 \
87
--http-route /=http://127.0.0.1:5173
88
89
+# Require Sui USDC x402 payment before proxying a route
90
+portal expose --name paid-app \
91
+ --http-route "/paid=http://127.0.0.1:3001 GET:0.01" \
92
+ --http-route /=http://127.0.0.1:5173 \
93
+ --x402-pay-to 0x...
94
+
95
# Raw TCP port (Minecraft, databases, SSH)
96
portal expose localhost:25565 --name minecraft --tcp
97
@@ -75,6 +99,10 @@ portal expose localhost:25565 --name minecraft --tcp
99
portal expose 3000 --multi-hop-depth 3
100
```
101
102
+See [CLI Reference](cmd/portal-tunnel/README.md) for the full route syntax and
103
+[API Reference](docs/src/routes/api-reference/+page.md#payments) for the x402
104
+helper endpoints.
105
+
106
### Keep tunnels running with Portal Agent
107
108
Use `portal agent run` when tunnels should keep running outside your terminal.
@@ -101,38 +129,49 @@ cd portal-tunnel && cp .env.example .env
129
docker compose up
130
```
131
104
-For public deployment with DNS automation (ACME), TCP/UDP port ranges, and relay policy, see [Deployment](docs/src/routes/deployment/+page.md).
132
+For public deployment with DNS automation (ACME), TCP/UDP port ranges, and relay
133
+policy, see [Deployment](docs/src/routes/deployment/+page.md).
134
135
## How End-to-End Encryption Works
136
137
```text
138
Browser
110
- → Relay SNI router (reads only routing token, forwards raw bytes)
111
- → Reverse session
112
- → Portal tunnel (performs TLS handshake locally, derives session keys)
113
- → Local service
139
+ -> Relay SNI router (reads only routing token, forwards raw bytes)
140
+ -> Reverse session
141
+ -> Portal tunnel (performs TLS handshake locally, derives session keys)
142
+ -> Local service
143
```
144
116
-1. The relay accepts the incoming connection and reads only the TLS ClientHello for SNI-based routing.
117
-2. It forwards the raw encrypted stream over the reverse session without terminating TLS.
118
-3. The Portal tunnel on your side completes the TLS handshake locally. Session keys are derived on your machine.
119
-4. For relay-hosted domains, the tunnel obtains certificate signatures via `/v1/sign`, using the relay only as a keyless signing oracle. The relay signs handshake digests but never receives session keys.
120
-5. After the handshake, the relay continues forwarding ciphertext without access to plaintext.
121
-
122
-When ECH is enabled, the relay also cannot see the actual tenant hostname. It routes by an opaque token derived from the tunnel identity, while the real SNI stays inside the ECH-protected ClientHello.
145
+1. The relay accepts the incoming connection and reads only the TLS ClientHello
146
+ for SNI-based routing.
147
+2. It forwards the raw encrypted stream over the reverse session without
148
+ terminating TLS.
149
+3. The Portal tunnel on your side completes the TLS handshake locally. Session
150
+ keys are derived on your machine.
151
+4. For relay-hosted domains, the tunnel obtains certificate signatures via
152
+ `/v1/sign`, using the relay only as a keyless signing oracle. The relay signs
153
+ handshake digests but never receives session keys.
154
+5. After the handshake, the relay continues forwarding ciphertext without access
155
+ to plaintext.
156
+
157
+When ECH is enabled, the relay also cannot see the actual tenant hostname. It
158
+routes by an opaque token derived from the tunnel identity, while the real SNI
159
+stays inside the ECH-protected ClientHello.
160
161
## How Multi-Hop Routing Works
162
163
```text
164
Browser
128
- → Entry relay (sees only the opaque route hostname)
129
- → Middle relay (sees only the next-hop token)
130
- → Exit relay (sees only the reverse session token)
131
- → Portal tunnel
132
- → Local service
165
+ -> Entry relay (sees only the opaque route hostname)
166
+ -> Middle relay (sees only the next-hop token)
167
+ -> Exit relay (sees only the reverse session token)
168
+ -> Portal tunnel
169
+ -> Local service
170
```
171
135
-Each relay in the chain knows only its immediate neighbors. No single relay holds the full path. Tenant TLS still terminates only on your side — no relay in the chain receives tenant TLS plaintext.
172
+Each relay in the chain knows only its immediate neighbors. No single relay
173
+holds the full path. Tenant TLS still terminates only on your side, so no relay
174
+in the chain receives tenant TLS plaintext.
175
176
## Public Relay Registry
177
@@ -142,7 +181,8 @@ Portal's official public relay registry is:
181
https://raw.githubusercontent.com/gosuda/portal-tunnel/main/registry.json
182
```
183
145
-Tunnel clients include this registry by default. If you operate a public Portal relay, open a pull request to add your relay URL to `registry.json`.
184
+Tunnel clients include this registry by default. If you operate a public Portal
185
+relay, open a pull request to add your relay URL to `registry.json`.
186
187
## Documentation
188
@@ -164,4 +204,4 @@ Tunnel clients include this registry by default. If you operate a public Portal
204
205
## License
206
167
-MIT License — see [LICENSE](LICENSE).
207
+MIT License - see [LICENSE](LICENSE).
README.zh-CN.md
+134
-28
@@ -1,31 +1,57 @@
1
-# PORTAL - Public Open Relay To Access Localhost
1
+# Portal - 面向 localhost 的自托管中继隧道
2
3
[English](./README.md) | [简体中文](./README.zh-CN.md)
4
5
<p align="center"><img width="800" alt="Portal Demo" src="./portal.gif" /></p>
6
7
-Portal 可以把本地服务发布到公网,不需要端口转发、NAT 配置或手动 DNS 配置。Portal 是一个可自托管的中继网络,默认让租户 TLS 在你的机器上终止,中继无法读取明文流量。
7
+<p align="center"><b>通过自托管或公共中继公开本地服务。</b><br/>无需端口转发。无需入站防火墙规则。无需手动 DNS 配置。无需账户。</p>
8
9
-## 功能
9
+## 为什么选择 Portal?
10
11
-- **本地服务公网 HTTPS**:通过反向连接发布 localhost 服务。
12
-- **端到端租户 TLS**:TLS 在 SDK/客户端侧终止,中继只做 SNI 路由和字节转发。
13
-- **MITM 自探测**:`portal expose` 默认在检测到疑似 TLS 终止时禁用该中继。
14
-- **中继发现和池化**:可以使用公共 registry、显式中继和运行时 discovery 结果。
15
-- **自托管中继**:可以连接公共中继,也可以运行自己的 relay server。
16
-- **Raw TCP/UDP**:支持专用 TCP 端口和 UDP/QUIC datagram backhaul。
17
-- **无需账号/API key**:注册使用 SIWE 身份签名。
11
+Portal 是一个本地隧道运行时和中继网络。它通过自托管或公共中继发布本地服务,把路由策略保留在隧道进程中,并避免依赖托管式厂商账户。
12
+
13
+- **自托管,完全开源** - 用一条命令运行你自己的中继。中继采用 MIT 许可证,没有企业版层级,没有功能门槛,也不会回传遥测。你的中继,你的规则。
14
+
15
+- **匿名中继网络** - 无需托管账户或中心化运营方即可连接公共中继。你可以把自托管中继和公共中继组合到一个池中,把信任拆分给你选择的多个独立运营方。
16
+
17
+- **端到端租户 TLS 和 ECH** - 因为中继是不可信的,Portal 会在用户端点而不是中继处终止租户 TLS。Portal 还提供 ECH,避免真实主机名以明文 SNI 暴露。
18
+
19
+- **内置 MITM 检测** - Portal 会在真实流量开始后主动自探测自己的连接。它会比较两端导出的 TLS 密钥材料,并把不匹配视为疑似中继侧 TLS 终止。
20
+
21
+- **多跳中继路由** - 将多个中继串联起来,使单个中继无法同时知道来源和目的地。使用 `--multi-hop-depth 3` 可以自动选择三跳路由。
22
+
23
+- **无账户,无 API Key** - 身份认证使用本地生成的 secp256k1 密钥对进行 SIWE 兼容签名。无需邮箱,无需注册,也没有厂商锁定。
24
+
25
+- **原生 x402 支付** - Routed HTTP 路径可以在代理前要求 Sui gasless USDC x402 支付。浏览器应用可以导入 `/x402/client.js`,原生客户端可以直接调用 `/x402/prepare` 并发送 `X-PAYMENT`。
26
+
27
+## 对比
28
+
29
+| | Portal | ngrok | Cloudflare Tunnel | frp |
30
+|---|---|---|---|---|
31
+| 公共 localhost URL | **是** | 是 | 是 | 是 |
32
+| 可自托管 | **是** | 仅企业版 | 否 | 是 |
33
+| 开源 | **MIT** | 否 | 仅客户端 | Apache 2.0 |
34
+| 自定义域名 | **是** | 付费套餐 | 是 | 是 |
35
+| 端到端租户 TLS | **是** | 否 | 否 | 否 |
36
+| SNI 隐藏 (ECH) | **是** | 否 | 否 | 否 |
37
+| MITM 自探测 | **内置** | 否 | 否 | 否 |
38
+| 多中继故障切换 | **是** | 托管 | 内置 | 否 |
39
+| 多跳路由 | **是** | 否 | 否 | 否 |
40
+| 需要账户 | **否** | 是 | 是 | 否 |
41
+| 原生 x402 支付 | **是** | 否 | 否 | 否 |
42
43
## 快速开始
44
21
-### 暴露本地服务
45
+### 公开本地服务
46
+
47
+**macOS / Linux:**
48
49
```bash
50
curl -fsSL https://github.com/gosuda/portal-tunnel/releases/latest/download/install.sh | bash
51
portal expose 3000
52
```
53
28
-Windows PowerShell:
54
+**Windows (PowerShell):**
55
56
```powershell
57
$ProgressPreference = 'SilentlyContinue'
@@ -33,9 +59,51 @@ irm https://github.com/gosuda/portal-tunnel/releases/latest/download/install.ps1
59
portal expose 3000
60
```
61
36
-安装细节见 [cmd/portal-tunnel/README.md](cmd/portal-tunnel/README.md)。
62
+Portal 会立即为你的本地应用打印一个公共 HTTPS URL。更多示例:
63
+
64
+```bash
65
+# 自定义名称和中继
66
+portal expose 3000 --name myapp --relays https://portal.example.com --discovery=false
67
+
68
+# 把前端和 API 挂到同一个 URL 后面
69
+portal expose --name myapp \
70
+ --http-route /api=http://127.0.0.1:3001 \
71
+ --http-route /=http://127.0.0.1:5173
72
+
73
+# 在代理某个路由前要求 Sui USDC x402 支付
74
+portal expose --name paid-app \
75
+ --http-route "/paid=http://127.0.0.1:3001 GET:0.01" \
76
+ --http-route /=http://127.0.0.1:5173 \
77
+ --x402-pay-to 0x...
78
+
79
+# 原始 TCP 端口(Minecraft、数据库、SSH)
80
+portal expose localhost:25565 --name minecraft --tcp
81
+
82
+# 三跳路由,获得更高匿名性
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`。
87
38
-### 运行自己的中继
88
+完整路由语法请参阅 [CLI Reference](cmd/portal-tunnel/README.md),x402 helper endpoint 请参阅 [API Reference](docs/src/routes/api-reference/+page.md#payments)。
89
+
90
+### 使用 Portal Agent 持续运行隧道
91
+
92
+当隧道需要在终端之外持续运行时,使用 `portal agent run`。它会作为本地 OS 服务运行,在一个 TOML 配置中保持所有隧道在线,并提供用于中继和多跳管理的 dashboard。
93
+
94
+```bash
95
+portal agent run --config config.toml
96
+portal agent dashboard --config config.toml
97
+portal agent restart
98
+portal agent stop
99
+
100
+# 前台模式会跳过 OS 服务安装。
101
+portal agent run --config config.toml --foreground
102
+```
103
+
104
+配置格式请参阅 [Portal Agent](docs/src/routes/portal-agent/+page.md)。
105
+
106
+### 运行你自己的中继
107
108
```bash
109
git clone https://github.com/gosuda/portal-tunnel
@@ -43,29 +111,67 @@ cd portal-tunnel && cp .env.example .env
111
docker compose up
112
```
113
46
-公网部署请看 [Deployment](docs/src/routes/deployment/+page.md),架构说明请看 [Architecture](docs/src/routes/architecture/+page.md)。
114
+关于带 DNS 自动化(ACME)、TCP/UDP 端口范围和中继策略的公网部署,请参阅 [Deployment](docs/src/routes/deployment/+page.md)。
115
48
-## 示例
116
+## 端到端加密如何工作
117
50
-| 示例 | 说明 |
51
-|---|---|
52
-| [nginx reverse proxy](docs/static/examples/nginx-proxy/) | 在 nginx 后部署 Portal,使用 L4 SNI 路由 |
53
-| [nginx + multi-service](docs/static/examples/nginx-proxy-multi-service/) | 在同一个 nginx 实例后运行 Portal 和其他服务 |
118
+```text
119
+Browser
120
+ -> Relay SNI router (只读取路由 token,转发原始字节)
121
+ -> Reverse session
122
+ -> Portal tunnel (在本地执行 TLS 握手,派生 session key)
123
+ -> Local service
124
+```
125
+
126
+1. 中继接受传入连接,并只读取 TLS ClientHello 中用于 SNI 路由的信息。
127
+2. 中继通过反向 session 转发原始加密流,而不终止 TLS。
128
+3. 你这边的 Portal 隧道在本地完成 TLS 握手。Session key 在你的机器上派生。
129
+4. 对于中继托管域名,隧道会通过 `/v1/sign` 获取证书签名,把中继仅用作 keyless signing oracle。中继签署握手摘要,但永远不会接收 session key。
130
+5. 握手完成后,中继继续转发密文,无法访问明文。
131
+
132
+启用 ECH 时,中继也看不到真实租户主机名。它会通过从隧道身份派生出的不透明 token 进行路由,而真实 SNI 保留在 ECH 保护的 ClientHello 中。
133
+
134
+## 多跳路由如何工作
135
+
136
+```text
137
+Browser
138
+ -> Entry relay (只看到不透明 route hostname)
139
+ -> Middle relay (只看到 next-hop token)
140
+ -> Exit relay (只看到 reverse session token)
141
+ -> Portal tunnel
142
+ -> Local service
143
+```
144
+
145
+链中的每个中继只知道自己的直接相邻节点。没有任何单个中继掌握完整路径。租户 TLS 仍然只在你这边终止,因此链中的任何中继都不会收到租户 TLS 明文。
146
147
## 公共中继 Registry
148
57
-官方公共中继 registry:
149
+Portal 官方公共中继 registry 是:
150
+
151
+```text
152
+https://raw.githubusercontent.com/gosuda/portal-tunnel/main/registry.json
153
+```
154
59
-`https://raw.githubusercontent.com/gosuda/portal-tunnel/main/registry.json`
155
+隧道客户端默认包含这个 registry。如果你运营公共 Portal 中继,可以提交 pull request,把你的中继 URL 添加到 `registry.json`。
156
61
-如果你运行公共 Portal 中继,可以提交 Pull Request 把中继 URL 加入 `registry.json`。
157
+## 文档
158
63
-## 安全模型
159
+- [CLI Reference](cmd/portal-tunnel/README.md)
160
+- [Concepts](docs/src/routes/concepts/+page.md)
161
+- [Portal Agent](docs/src/routes/portal-agent/+page.md)
162
+- [Wallet and ENS](docs/src/routes/wallet-and-ens/+page.md)
163
+- [Security Model](docs/src/routes/security-model/+page.md)
164
+- [Architecture](docs/src/routes/architecture/+page.md)
165
+- [Deployment](docs/src/routes/deployment/+page.md)
166
+- [Configuration Reference](docs/src/routes/configuration/+page.md)
167
65
-Portal 的默认 stream 路径中,relay 只读取 TLS ClientHello 中的 SNI 来选择 lease,然后转发加密字节。SDK/客户端侧通过 relay 的 `/v1/sign` keyless signer 完成租户 TLS 握手,但会在本地派生 session key。relay API TLS、租户 TLS、QUIC datagram backhaul TLS 是不同的信任边界。
168
+## 贡献
169
67
-Raw TCP 和 UDP 端口模式不自动增加租户 TLS。如果这些模式需要保密性,请使用应用层加密。
170
+1. Fork 这个仓库。
171
+2. 创建功能分支(`git checkout -b feature/amazing-feature`)。
172
+3. 用聚焦的测试或文档完成修改。
173
+4. 打开 pull request。
174
69
-## License
175
+## 许可证
176
71
-MIT License - see [LICENSE](LICENSE)
177
+MIT License - see [LICENSE](LICENSE).
cmd/payment-app/main.go
+1
-1
@@ -74,7 +74,7 @@ func run(args []string) error {
74
utils.StringFlag(fs, &cfg.photoURL, "photo-url", defaultPhotoURL, "image URL revealed after payment")
75
utils.BoolFlag(fs, &cfg.hide, "hide", false, "hide this lease from listings")
76
utils.BoolFlag(fs, &cfg.x402Testnet, "x402-testnet", true, "use Sui testnet for x402 payments")
77
- utils.StringFlag(fs, &cfg.x402PayTo, "x402-pay-to", "0xbf3cc34e9b676d5f0588b035b9fdb8972306430b19d8239cfee121b60e723ab6", "Sui USDC recipient address")
77
+ utils.StringFlag(fs, &cfg.x402PayTo, "x402-pay-to", "", "Sui USDC recipient address")
78
utils.StringFlag(fs, &cfg.x402Amount, "x402-amount", "0.01", "USDC amount")
79
utils.RepeatedStringFlag(fs, &cfg.x402RPCs, "x402-rpc", "Sui RPC endpoint; repeat to try multiple endpoints before defaults")
80
fs.IntVar(&cfg.x402MaxTimeoutSeconds, "x402-max-timeout", 0, "x402 max payment timeout seconds advertised to clients")
cmd/payment-app/static/index.html
+5
-1
@@ -103,6 +103,10 @@
103
statusEl.textContent = value;
104
}
105
106
+ function setPaymentEvent(event) {
107
+ setStatus(event.message);
108
+ }
109
+
110
function currentWallets() {
111
wallets = getSuiWallets({ network: config.network });
112
return wallets;
@@ -233,7 +237,7 @@
237
account,
238
network: config.network,
239
preparePath: config.preparePath,
236
- onStatus: setStatus,
240
+ onEvent: setPaymentEvent,
241
});
242
if (!protectedResponse.ok) {
243
throw new Error(await protectedResponse.text());
cmd/portal-tunnel/README.md
+4
-3
@@ -56,9 +56,10 @@ portal expose --name paid-app \
56
```
57
58
Routed HTTP serves `/x402/client.js` and `/x402/prepare` on the tunnel origin so
59
-an upstream frontend can run the same in-page Sui wallet payment flow as the
60
-standalone payment app. The tunnel still verifies and settles payment before
61
-proxying the paid route.
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.
63
64
Raw TCP and UDP:
65
docker-compose.yml
+2
-2
@@ -81,8 +81,8 @@ services:
81
context: .
82
dockerfile: Dockerfile
83
stop_grace_period: 30s
84
- # ports:
85
- # - "${WIREGUARD_PORT:-51820}:${WIREGUARD_PORT:-51820}/udp"
84
+ ports:
85
+ - "${WIREGUARD_PORT:-51820}:${WIREGUARD_PORT:-51820}/udp"
86
# Uncomment for UDP backhaul, public UDP lease ports, and raw TCP lease ports as needed.
87
# - "443:443/udp"
88
# - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}/udp"
docs/src/routes/api-reference/+page.md
+2
-1
@@ -125,7 +125,8 @@ are local tunnel configuration and are not part of the relay lease API.
125
Paid routed HTTP tunnels additionally expose `/x402/prepare` and
126
`/x402/client.js` on the public tunnel origin. Those are tunnel-owned helper
127
endpoints for app frontends, not relay API routes, and they do not use the
128
-`/api` prefix.
128
+`/api` prefix. `/x402/client.js` is browser-only; native clients call
129
+`/x402/prepare` directly and send `X-PAYMENT` on the protected request.
130
131
| Method | Path | Auth | Body | Response |
132
|--------|------|------|------|----------|
docs/src/routes/cli-reference/+page.md
+36
-4
@@ -190,21 +190,53 @@ method on that route prefix is paid.
190
191
The routed HTTP handler also serves `/x402/client.js` and `/x402/prepare` on the
192
public tunnel origin. Frontends served by one of the routes can use the shared
193
-browser client for an in-page Sui wallet flow:
193
+browser-only Sui wallet client for an in-page payment flow:
194
195
```js
196
import { getSuiWallets, x402Fetch } from '/x402/client.js';
197
198
-const [wallet] = getSuiWallets({ network: 'sui:testnet' });
199
-const [account] = await wallet.accounts('sui:testnet');
198
+const [wallet] = getSuiWallets();
199
+if (!wallet) {
200
+ throw new Error('Install a Sui wallet');
201
+}
202
+
203
+const [account] = await wallet.accounts();
204
+if (!account) {
205
+ throw new Error('Connect a Sui account');
206
+}
207
208
const response = await x402Fetch('/paid/photo', { method: 'GET' }, {
209
wallet,
210
account,
204
- network: 'sui:testnet',
211
+ onEvent: (event) => console.log(event.type, event.message),
212
});
213
```
214
215
+`x402Fetch()` is a convenience wrapper: it asks `/x402/prepare` for the payment
216
+transaction, asks the wallet to sign it, then retries the protected request with
217
+an `X-PAYMENT` header. `onEvent` receives structured progress events; the older
218
+`onStatus(message)` callback is still accepted for simple UIs. Routed HTTP
219
+payments currently use Sui mainnet, so omit `network` or pass `sui:mainnet`.
220
+
221
+Native clients should not load `/x402/client.js`. Call `POST /x402/prepare` with
222
+`{ "sender": "...", "method": "GET", "path": "/paid/photo" }`, execute
223
+`prepareTransaction.transaction` first when present, sign
224
+`paymentTransaction.transaction`, and send the resulting x402 payload as the
225
+`X-PAYMENT` header on the protected request:
226
+
227
+```js
228
+const payload = {
229
+ x402Version: prepared.x402Version,
230
+ payload: {
231
+ signature,
232
+ transaction: prepared.paymentTransaction.transaction,
233
+ },
234
+ accepted: prepared.paymentRequirements,
235
+ resource: prepared.resource,
236
+};
237
+const header = base64(JSON.stringify(payload));
238
+```
239
+
240
The frontend integration is optional. Requests without a valid `X-PAYMENT`
241
header still receive x402 payment-required responses from the tunnel.
242
docs/src/routes/concepts/+page.md
+6
-4
@@ -96,10 +96,12 @@ portal expose --name paid-app \
96
```
97
98
The tunnel serves `/x402/client.js` and `/x402/prepare` on the same public
99
-origin. A frontend mounted through the tunnel can import `/x402/client.js` and
100
-call `x402Fetch()` from its own UI, so the Sui wallet flow stays in the app
101
-instead of requiring a separate payment redirect. The tunnel still verifies and
102
-settles the payment before proxying the protected request.
99
+origin. A browser frontend mounted through the tunnel can import
100
+`/x402/client.js` and call `x402Fetch()` from its own UI, so the Sui wallet flow
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.
105
106
## Dedicated Raw TCP
107
docs/src/routes/configuration/+page.md
+6
-4
@@ -266,10 +266,12 @@ Tunnel fields mirror `portal expose` flags:
266
| `http_routes[].methods` | string array | Optional HTTP methods that require payment on that route; empty means every method |
267
268
When any routed HTTP entry has `amount`, the tunnel also serves
269
-`/x402/client.js` and `/x402/prepare` on the public tunnel origin. Frontends
270
-served by another route in the same tunnel can import `/x402/client.js` and use
271
-`x402Fetch()` to run the same Sui wallet payment flow as the standalone payment
272
-app. Payment is still enforced by the tunnel on the paid route prefix.
269
+`/x402/client.js` and `/x402/prepare` on the public tunnel origin. Browser
270
+frontends served by another route in the same tunnel can import
271
+`/x402/client.js` and use `x402Fetch()` to run the same Sui wallet payment flow
272
+as the standalone payment app. Native clients use `/x402/prepare` directly and
273
+send the signed payload as `X-PAYMENT`. Payment is still enforced by the tunnel
274
+on the paid route prefix.
275
276
For a task-oriented walkthrough, see [Portal Agent](/portal-agent).
277
docs/src/routes/portal-agent/+page.md
+5
-3
@@ -84,9 +84,11 @@ upstream = "http://127.0.0.1:5173"
84
```
85
86
If a route has `amount`, the tunnel serves `/x402/client.js` and
87
-`/x402/prepare` on the public tunnel origin. A frontend served by the `/` route
88
-can import the helper and call `x402Fetch()` from its own UI; the tunnel still
89
-verifies and settles payment before proxying the paid route.
87
+`/x402/prepare` on the public tunnel origin. A browser frontend served by the
88
+`/` route can import the helper and call `x402Fetch()` from its own UI. Native
89
+clients use `/x402/prepare` directly and send the signed payload as
90
+`X-PAYMENT`. The tunnel still verifies and settles payment before proxying the
91
+paid route.
92
93
Relative paths in the config are resolved from the config file directory.
94
go.sum
+2
-4
@@ -218,8 +218,6 @@ github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0U
218
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
219
github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008 h1:KuP/5VlPJwqZNyAV5U60C/j8Pc5O8ENkWPTgP7mEvj0=
220
github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
221
-github.com/gosuda/x402-facilitator v0.0.1 h1:Jo4ctVestDMw6B4dkvodZLj6a5rTt6efpoK4VzWoO5k=
222
-github.com/gosuda/x402-facilitator v0.0.1/go.mod h1:4hLowxzMiNVcLInkoD9BUDuGbzl86Vj3nO++QXh8OYg=
221
github.com/gosuda/x402-facilitator v0.0.3-0.20260604031337-6baece37375a h1:AQoH9Wigm4LhKgHLDTP1/4fnuv7WXC9HA7HzvPEHbKY=
222
github.com/gosuda/x402-facilitator v0.0.3-0.20260604031337-6baece37375a/go.mod h1:4hLowxzMiNVcLInkoD9BUDuGbzl86Vj3nO++QXh8OYg=
223
github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac=
@@ -293,6 +291,8 @@ github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0
291
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
292
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
293
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
294
+github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
295
+github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
296
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
297
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
298
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -424,8 +424,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
424
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
425
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
426
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
427
-github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
428
-github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
427
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
428
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
429
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
portal/x402/client.js
+73
-14
@@ -1,5 +1,8 @@
1
-import { getWallets } from 'https://esm.sh/@wallet-standard/app';
2
-import { Transaction } from 'https://esm.sh/@mysten/sui/transactions';
1
+// Browser-only Sui wallet helper for Portal x402 routes.
2
+// Native clients should call /x402/prepare, sign the returned transaction with
3
+// their own Sui runtime, then send the resulting payload as X-PAYMENT.
4
+import { getWallets } from 'https://esm.sh/@wallet-standard/app@1.1.0';
5
+import { Transaction } from 'https://esm.sh/@mysten/sui@1.32.0/transactions';
6
7
const walletAPI = getWallets();
8
@@ -76,6 +79,7 @@ export function onSuiWalletChange(callback) {
79
80
export async function prepareX402Payment(options = {}) {
81
const fetcher = options.fetch || fetch.bind(globalThis);
82
+ const signal = options.signal;
83
const method = String(options.method || 'GET').trim().toUpperCase();
84
const path = String(options.path || '').trim();
85
if (!path || !path.startsWith('/')) {
@@ -87,7 +91,7 @@ export async function prepareX402Payment(options = {}) {
91
throw new Error('No Sui wallet selected');
92
}
93
90
- options.onStatus?.('Connecting wallet');
94
+ emitPaymentEvent(options, 'wallet.connect', 'Connecting wallet');
95
const network = String(options.network || '').trim();
96
const account = options.account || await wallet.connect(network, options.address);
97
if (!account?.address) {
@@ -97,10 +101,10 @@ export async function prepareX402Payment(options = {}) {
101
throw new Error(`Connected account does not advertise ${network}`);
102
}
103
100
- options.onStatus?.('Preparing USDC transaction');
104
+ emitPaymentEvent(options, 'payment.prepare', 'Preparing USDC transaction', { method, path });
105
const prepareURL = options.preparePath || '/x402/prepare';
106
const prepareBody = { sender: account.address, method, path };
103
- let prepared = await requestPrepare(fetcher, prepareURL, prepareBody);
107
+ let prepared = await requestPrepare(fetcher, prepareURL, prepareBody, signal);
108
let paymentNetwork = String(prepared.paymentRequirements?.network || network).trim();
109
if (paymentNetwork && Array.isArray(account.chains) && !account.chains.includes(paymentNetwork)) {
110
throw new Error(`Connected account does not advertise ${paymentNetwork}`);
@@ -110,18 +114,27 @@ export async function prepareX402Payment(options = {}) {
114
if (!wallet.executeTransaction) {
115
throw new Error('Selected wallet cannot execute the USDC prepare transaction');
116
}
113
- options.onStatus?.('Preparing object balance in wallet');
117
+ emitPaymentEvent(options, 'balance.prepare', 'Preparing object balance in wallet', { network: paymentNetwork });
118
const prepareResult = await wallet.executeTransaction(account, Transaction.from(fromBase64(prepared.prepareTransaction.transaction)), paymentNetwork);
119
const prepareStatus = prepareResult?.effects?.status?.status || prepareResult?.effects?.status;
120
if (prepareStatus && prepareStatus !== 'success') {
121
throw new Error(prepareResult.effects?.status?.error || 'USDC prepare transaction failed');
122
}
123
120
- options.onStatus?.('Waiting for prepared balance');
121
- for (let attempt = 0; attempt < 20 && prepared.prepareTransaction?.transaction; attempt += 1) {
122
- await new Promise((resolve) => setTimeout(resolve, 1000));
123
- prepared = await requestPrepare(fetcher, prepareURL, prepareBody);
124
+ const pollAttempts = nonNegativeIntegerOption(options.preparePollAttempts, 20);
125
+ const pollIntervalMs = positiveIntegerOption(options.preparePollIntervalMs, 1000);
126
+ emitPaymentEvent(options, 'balance.wait', 'Waiting for prepared balance', {
127
+ attempts: pollAttempts,
128
+ intervalMs: pollIntervalMs,
129
+ });
130
+ for (let attempt = 0; attempt < pollAttempts && prepared.prepareTransaction?.transaction; attempt += 1) {
131
+ await delay(pollIntervalMs, signal);
132
+ prepared = await requestPrepare(fetcher, prepareURL, prepareBody, signal);
133
paymentNetwork = String(prepared.paymentRequirements?.network || paymentNetwork).trim();
134
+ emitPaymentEvent(options, 'balance.poll', 'Checking prepared balance', {
135
+ attempt: attempt + 1,
136
+ attempts: pollAttempts,
137
+ });
138
}
139
if (prepared.prepareTransaction?.transaction) {
140
throw new Error('Prepared USDC balance is not indexed yet');
@@ -131,7 +144,7 @@ export async function prepareX402Payment(options = {}) {
144
if (!prepared.paymentTransaction?.transaction) {
145
throw new Error('Payment prepare response is missing a payment transaction');
146
}
134
- options.onStatus?.('Signing x402 payment');
147
+ emitPaymentEvent(options, 'payment.sign', 'Signing x402 payment', { network: paymentNetwork });
148
const signed = await wallet.signTransaction(account, Transaction.from(fromBase64(prepared.paymentTransaction.transaction)), paymentNetwork);
149
const signature = typeof signed?.signature === 'string' ? signed.signature : (Array.isArray(signed?.signatures) ? signed.signatures[0] : '');
150
const transaction = signed?.bytes || signed?.transactionBlockBytes || prepared.paymentTransaction.transaction;
@@ -162,22 +175,25 @@ export async function prepareX402Payment(options = {}) {
175
176
export async function x402Fetch(input, init = {}, options = {}) {
177
const request = input instanceof Request ? new Request(input, init) : new Request(new URL(String(input), globalThis.location.href).href, init);
178
+ const signal = options.signal || request.signal;
179
const paid = await prepareX402Payment({
180
...options,
181
+ signal,
182
method: request.method,
183
path: options.path || new URL(request.url).pathname,
184
});
185
171
- options.onStatus?.('Settling payment');
186
+ emitPaymentEvent(options, 'payment.settle', 'Settling payment');
187
const headers = new Headers(request.headers);
188
headers.set('X-PAYMENT', paid.paymentHeader);
174
- return (options.fetch || fetch.bind(globalThis))(new Request(request, { headers }));
189
+ return (options.fetch || fetch.bind(globalThis))(new Request(request, { headers, signal }));
190
}
191
177
-async function requestPrepare(fetcher, prepareURL, prepareBody) {
192
+async function requestPrepare(fetcher, prepareURL, prepareBody, signal) {
193
const response = await fetcher(prepareURL, {
194
method: 'POST',
195
headers: { 'Content-Type': 'application/json' },
196
+ signal,
197
body: JSON.stringify(prepareBody),
198
});
199
if (!response.ok) {
@@ -186,6 +202,49 @@ async function requestPrepare(fetcher, prepareURL, prepareBody) {
202
return response.json();
203
}
204
205
+function emitPaymentEvent(options, type, message, data = {}) {
206
+ options.onEvent?.({ type, message, data });
207
+ options.onStatus?.(message);
208
+}
209
+
210
+function nonNegativeIntegerOption(value, fallback) {
211
+ const number = Number(value);
212
+ if (!Number.isFinite(number) || number < 0) {
213
+ return fallback;
214
+ }
215
+ return Math.floor(number);
216
+}
217
+
218
+function positiveIntegerOption(value, fallback) {
219
+ const number = Number(value);
220
+ if (!Number.isFinite(number) || number <= 0) {
221
+ return fallback;
222
+ }
223
+ return Math.floor(number);
224
+}
225
+
226
+function delay(ms, signal) {
227
+ if (signal?.aborted) {
228
+ return Promise.reject(abortError());
229
+ }
230
+ return new Promise((resolve, reject) => {
231
+ const timeout = setTimeout(resolve, ms);
232
+ signal?.addEventListener('abort', () => {
233
+ clearTimeout(timeout);
234
+ reject(abortError());
235
+ }, { once: true });
236
+ });
237
+}
238
+
239
+function abortError() {
240
+ if (typeof DOMException === 'function') {
241
+ return new DOMException('Aborted', 'AbortError');
242
+ }
243
+ const error = new Error('Aborted');
244
+ error.name = 'AbortError';
245
+ return error;
246
+}
247
+
248
function normalizeAccounts(value) {
249
const accounts = Array.isArray(value) ? value : (value ? [value] : []);
250
return accounts.map((account) => {