.
Kim committed
Jun 5, 2026 at 18:24 UTC
763f018b5cace459a776fc83ef266d4cd5892051
22 files changed
+1244
-851
cmd/payment-app/handler.go
+4
-133
@@ -14,139 +14,14 @@ import (
14
"github.com/gosuda/portal-tunnel/v2/utils"
15
)
16
17
-//go:embed static/index.html static/style.css
17
+//go:embed static/index.html static/photo.html static/style.css
18
var staticFiles embed.FS
19
20
const paidPhotoPath = "/paid/photo"
21
22
var (
23
indexPage = template.Must(template.ParseFS(staticFiles, "static/index.html"))
24
- photoPage = template.Must(template.New("photo").Parse(`<!DOCTYPE html>
25
-<html lang="en">
26
-<head>
27
- <meta charset="UTF-8">
28
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
29
- <title>{{.PageTitle}}</title>
30
- <meta name="description" content="{{.PageDescription}}">
31
- <meta property="og:type" content="website">
32
- <meta property="og:title" content="{{.PageTitle}}">
33
- <meta property="og:description" content="{{.PageDescription}}">
34
- <meta property="og:image" content="{{.OGImage}}">
35
- <meta property="og:url" content="{{.URL}}">
36
- <meta name="twitter:card" content="summary_large_image">
37
- <meta name="twitter:title" content="{{.PageTitle}}">
38
- <meta name="twitter:description" content="{{.PageDescription}}">
39
- <meta name="twitter:image" content="{{.OGImage}}">
40
- <style>
41
- * { box-sizing: border-box; }
42
- body {
43
- margin: 0;
44
- min-height: 100vh;
45
- display: flex;
46
- padding: 0;
47
- background: #f7f8fb;
48
- color: #182033;
49
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
50
- }
51
- main {
52
- display: grid;
53
- width: 100%;
54
- min-height: 100vh;
55
- grid-template-rows: minmax(0, 1fr) auto;
56
- overflow: hidden;
57
- background: #ffffff;
58
- }
59
- img {
60
- width: 100%;
61
- height: 100%;
62
- min-height: 0;
63
- display: block;
64
- object-fit: contain;
65
- background: #111827;
66
- }
67
- section {
68
- display: grid;
69
- gap: 10px;
70
- padding: 18px;
71
- border-top: 1px solid #e5eaf0;
72
- }
73
- .eyebrow {
74
- margin: 0;
75
- color: #0e7490;
76
- font-size: 12px;
77
- font-weight: 800;
78
- text-transform: uppercase;
79
- letter-spacing: 0;
80
- }
81
- h1 {
82
- margin: 0;
83
- color: #111827;
84
- font-size: 25px;
85
- line-height: 1.18;
86
- }
87
- p {
88
- margin: 0;
89
- color: #4b5565;
90
- font-size: 15px;
91
- line-height: 1.55;
92
- }
93
- dl {
94
- display: grid;
95
- grid-template-columns: repeat(3, 1fr);
96
- gap: 10px;
97
- margin: 4px 0 0;
98
- }
99
- div { min-width: 0; }
100
- dt {
101
- color: #667085;
102
- font-size: 12px;
103
- font-weight: 700;
104
- }
105
- dd {
106
- margin: 4px 0 0;
107
- overflow-wrap: anywhere;
108
- color: #111827;
109
- font-size: 13px;
110
- font-weight: 750;
111
- }
112
- @media (max-width: 640px) {
113
- section { padding: 14px; }
114
- dl { grid-template-columns: 1fr; }
115
- }
116
- </style>
117
-</head>
118
-<body>
119
- <main>
120
- <img src="{{.PhotoURL}}" alt="Unlocked protected image">
121
- <section>
122
- <p class="eyebrow">Payment complete</p>
123
- <h1>Image unlocked</h1>
124
- <p>The protected image is available after the {{.Amount}} atomic USDC x402 settlement.</p>
125
- <dl>
126
- <div>
127
- <dt>Amount</dt>
128
- <dd>{{.Amount}} atomic USDC</dd>
129
- </div>
130
- <div>
131
- <dt>Network</dt>
132
- <dd>{{.NetworkName}}</dd>
133
- </div>
134
- <div>
135
- <dt>Recipient</dt>
136
- <dd>{{.RecipientAddress}}</dd>
137
- </div>
138
- {{if .TransactionID}}
139
- <div>
140
- <dt>Transaction</dt>
141
- <dd>{{.TransactionID}}</dd>
142
- </div>
143
- {{end}}
144
- </dl>
145
- </section>
146
- </main>
147
-</body>
148
-</html>
149
-`))
24
+ photoPage = template.Must(template.ParseFS(staticFiles, "static/photo.html"))
25
)
26
27
type paymentHandlerConfig struct {
@@ -167,7 +42,6 @@ type paymentHandler struct {
42
asset string
43
amount string
44
payTo string
170
- endpoints []string
45
photoURL string
46
}
47
@@ -212,7 +86,6 @@ func newHandler(cfg paymentHandlerConfig) (http.Handler, error) {
86
handler.asset = payment.Asset
87
handler.amount = payment.Amount
88
handler.payTo = payment.PayTo
215
- handler.endpoints = append([]string(nil), payment.Endpoints...)
89
handler.photoURL = strings.TrimSpace(cfg.PhotoURL)
90
91
staticFS, err := fs.Sub(staticFiles, "static")
@@ -221,6 +94,7 @@ func newHandler(cfg paymentHandlerConfig) (http.Handler, error) {
94
}
95
mux := http.NewServeMux()
96
mux.Handle("/static/style.css", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
97
+ mux.HandleFunc(types.X402ClientPath, x402.ServeClientJS)
98
mux.Handle(types.X402PreparePath, paidPhotoHandler)
99
mux.HandleFunc("/", handler.handleIndex)
100
mux.Handle(paidPhotoPath, paidPhotoHandler)
@@ -264,9 +138,6 @@ func (h *paymentHandler) newPaymentPageData(r *http.Request) paymentPageData {
138
"preparePath": types.X402PreparePath,
139
"protectedPath": paidPhotoPath,
140
}
267
- if len(h.endpoints) > 0 {
268
- config["endpoints"] = append([]string(nil), h.endpoints...)
269
- }
141
configJSON, err := json.Marshal(config)
142
if err != nil {
143
configJSON = []byte("{}")
@@ -280,7 +151,7 @@ func (h *paymentHandler) newPaymentPageData(r *http.Request) paymentPageData {
151
Network: h.network,
152
NetworkName: h.networkName,
153
Asset: h.asset,
283
- Amount: h.amount,
154
+ Amount: x402.FormatUSDCAtomicAmount(h.amount),
155
PhotoURL: h.photoURL,
156
RecipientAddress: h.payTo,
157
ConfigJSON: template.JS(string(configJSON)),
cmd/payment-app/main.go
+5
-5
@@ -59,7 +59,7 @@ func run(args []string) error {
59
cfg := paymentConfig{}
60
fs := utils.NewFlagSet("payment-app", printUsage)
61
62
- utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://gosunuts.xyz", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with bootstrap relays when discovery is enabled)", "RELAYS")
62
+ utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://localhost", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with bootstrap relays when discovery is enabled)", "RELAYS")
63
utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", false, "include bootstrap relays and enable discovery", "DISCOVERY")
64
utils.BoolFlagEnv(fs, &cfg.banMITM, "ban-mitm", false, "ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
65
utils.StringFlagEnv(fs, &cfg.identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH")
@@ -74,8 +74,8 @@ 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", "0xdb3585edba7e946c7e5c3827bdc0fc92d20efbb88520a82699ef4504b61aada2", "Sui USDC recipient address")
78
- utils.StringFlag(fs, &cfg.x402Amount, "x402-amount", "10000", "USDC amount in atomic units")
77
+ utils.StringFlag(fs, &cfg.x402PayTo, "x402-pay-to", "0xbf3cc34e9b676d5f0588b035b9fdb8972306430b19d8239cfee121b60e723ab6", "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")
81
fs.IntVar(&cfg.x402RequestTimeout, "x402-request-timeout", 30, "Sui RPC and x402 verify/settle timeout seconds")
@@ -187,8 +187,8 @@ func printUsage(w io.Writer) {
187
},
188
[]string{
189
"payment-app --x402-pay-to 0x...",
190
- "payment-app --name paid-photo --x402-pay-to 0x... --x402-amount 10000",
191
- "payment-app --x402-testnet=false --x402-pay-to 0x... --x402-amount 10000",
190
+ "payment-app --name paid-photo --x402-pay-to 0x... --x402-amount 0.01",
191
+ "payment-app --x402-testnet=false --x402-pay-to 0x... --x402-amount 0.01",
192
},
193
)
194
}
cmd/payment-app/static/index.html
+56
-295
@@ -35,7 +35,7 @@
35
<dl class="facts">
36
<div>
37
<dt>Amount</dt>
38
- <dd>{{.Amount}} atomic USDC</dd>
38
+ <dd>{{.Amount}}</dd>
39
</div>
40
<div>
41
<dt>Network</dt>
@@ -83,11 +83,9 @@
83
</main>
84
85
<script type="module">
86
- import { getWallets } from 'https://esm.sh/@wallet-standard/app';
87
- import { Transaction } from 'https://esm.sh/@mysten/sui/transactions';
86
+ import { getSuiWallets, onSuiWalletChange, x402Fetch } from '/x402/client.js';
87
88
const config = JSON.parse(document.getElementById('payment-config').textContent || '{}');
90
- const walletsApi = getWallets();
89
const walletSelect = document.getElementById('walletSelect');
90
const accountSelect = document.getElementById('accountSelect');
91
const unlockButton = document.getElementById('unlockButton');
@@ -97,23 +95,17 @@
95
const paymentFrame = document.getElementById('paymentFrame');
96
const resetViewer = document.getElementById('resetViewer');
97
const viewerTitle = document.getElementById('viewerTitle');
100
- let connectedWallet = null;
98
+ let wallets = [];
99
+ let connectedWalletIndex = '';
100
let connectedAccounts = [];
101
102
function setStatus(value) {
103
statusEl.textContent = value;
104
}
105
107
- function supportsPayment(candidate) {
108
- const features = candidate?.features || {};
109
- return Boolean(
110
- features['standard:connect'] &&
111
- (features['sui:signTransaction'] || features['sui:signTransactionBlock'])
112
- );
113
- }
114
-
106
function currentWallets() {
116
- return walletsApi.get().filter(supportsPayment);
107
+ wallets = getSuiWallets({ network: config.network });
108
+ return wallets;
109
}
110
111
function resetAccountSelect() {
@@ -125,49 +117,49 @@
117
}
118
119
function refreshWallets() {
128
- const wallets = currentWallets();
129
- walletSelect.replaceChildren(...wallets.map((candidate, index) => {
120
+ const selectedWalletIndex = walletSelect.value;
121
+ const nextWallets = currentWallets();
122
+ walletSelect.replaceChildren(...nextWallets.map((wallet, index) => {
123
const option = document.createElement('option');
124
option.value = String(index);
132
- option.textContent = candidate.name || `Wallet ${index + 1}`;
125
+ option.textContent = wallet.name;
126
return option;
127
}));
135
- const hasWallets = wallets.length > 0;
128
+ if (selectedWalletIndex && Number(selectedWalletIndex) < nextWallets.length) {
129
+ walletSelect.value = selectedWalletIndex;
130
+ }
131
+ const hasWallets = nextWallets.length > 0;
132
walletSelect.disabled = !hasWallets;
133
unlockButton.disabled = !hasWallets;
134
unlockButton.textContent = 'Connect and pay';
139
- connectedWallet = null;
135
+ connectedWalletIndex = '';
136
connectedAccounts = [];
137
resetAccountSelect();
138
setStatus(hasWallets ? 'Select a wallet and continue' : 'Install a Sui wallet extension');
139
}
140
145
- function normalizeAccounts(value) {
146
- const accounts = Array.isArray(value) ? value : (value ? [value] : []);
147
- return accounts.map((account) => {
148
- if (typeof account === 'string') {
149
- return { address: account };
150
- }
151
- return account && typeof account.address === 'string' ? account : null;
152
- }).filter(Boolean);
153
- }
154
-
155
- function pickAccount(accounts) {
156
- return accounts.find((candidate) => Array.isArray(candidate.chains) && candidate.chains.includes(config.network)) || accounts[0] || null;
141
+ function accountKey(account) {
142
+ return String(account?.address || '').trim().toLowerCase();
143
}
144
159
- function compatibleAccounts(accounts) {
160
- const matching = accounts.filter((account) => !Array.isArray(account.chains) || account.chains.includes(config.network));
161
- return matching.length > 0 ? matching : accounts;
145
+ function shortAddress(value) {
146
+ const address = String(value || '').trim();
147
+ if (address.length <= 18) {
148
+ return address;
149
+ }
150
+ return `${address.slice(0, 10)}...${address.slice(-6)}`;
151
}
152
164
- function accountKey(account) {
165
- return String(account?.address || '').trim().toLowerCase();
153
+ function accountByAddress(accounts, address) {
154
+ const key = String(address || '').trim().toLowerCase();
155
+ return accounts.find((account) => account.address === address) ||
156
+ accounts.find((account) => accountKey(account) === key) ||
157
+ null;
158
}
159
168
- function mergeAccounts(primary, secondary) {
160
+ function setAccountOptions(accounts, preferredAddress) {
161
const seen = new Set();
170
- return [...primary, ...secondary].filter((account) => {
162
+ connectedAccounts = accounts.filter((account) => {
163
const key = accountKey(account);
164
if (!key || seen.has(key)) {
165
return false;
@@ -175,253 +167,46 @@
167
seen.add(key);
168
return true;
169
});
178
- }
179
-
180
- function shortAddress(value) {
181
- const address = String(value || '').trim();
182
- if (address.length <= 18) {
183
- return address;
184
- }
185
- return `${address.slice(0, 10)}...${address.slice(-6)}`;
186
- }
187
-
188
- function setAccountOptions(accounts, preferredAddress) {
189
- connectedAccounts = compatibleAccounts(accounts);
170
accountSelect.replaceChildren(...connectedAccounts.map((account) => {
171
const option = document.createElement('option');
172
option.value = account.address;
173
option.textContent = shortAddress(account.address);
174
return option;
175
}));
196
- const preferred = String(preferredAddress || '').trim();
197
- const preferredAccount = preferred ? accountByAddress(connectedAccounts, preferred) : null;
198
- if (preferredAccount) {
199
- accountSelect.value = preferredAccount.address;
176
+ const preferred = accountByAddress(connectedAccounts, preferredAddress);
177
+ if (preferred) {
178
+ accountSelect.value = preferred.address;
179
}
180
accountSelect.disabled = connectedAccounts.length === 0;
181
}
182
204
- function selectedAccountFrom(response) {
205
- return pickAccount(normalizeAccounts(
206
- response?.account || response?.selectedAccount || response?.currentAccount || response?.address
207
- ));
208
- }
209
-
210
- function accountByAddress(accounts, address) {
211
- const key = String(address || '').trim().toLowerCase();
212
- return accounts.find((account) => account.address === address) ||
213
- accounts.find((account) => accountKey(account) === key) ||
214
- null;
215
- }
216
-
217
- function pickConnectedAccount(response, wallet, hadAccountSelection) {
218
- const selectedAccount = selectedAccountFrom(response);
219
- const responseAccounts = normalizeAccounts(response?.accounts);
220
- const accounts = mergeAccounts(
221
- mergeAccounts(selectedAccount ? [selectedAccount] : [], responseAccounts),
222
- normalizeAccounts(wallet.accounts || [])
223
- );
224
- setAccountOptions(accounts, selectedAccount?.address || accountSelect.value);
225
- if (!selectedAccount && connectedAccounts.length > 1 && !hadAccountSelection) {
226
- return null;
227
- }
228
- const selectedAddress = accountSelect.value;
229
- return accountByAddress(connectedAccounts, selectedAddress) ||
230
- selectedAccount ||
231
- pickAccount(connectedAccounts);
232
- }
233
-
183
async function connectWallet() {
235
- const wallet = currentWallets()[Number(walletSelect.value)];
184
+ const selectedWalletIndex = walletSelect.value;
185
+ const wallet = wallets[Number(selectedWalletIndex)] || currentWallets()[Number(selectedWalletIndex)];
186
if (!wallet) {
187
throw new Error('No Sui wallet selected');
188
}
239
- const hadAccountSelection = connectedWallet === wallet && accountSelect.value !== '';
240
- const response = await wallet.features['standard:connect'].connect();
241
- connectedWallet = wallet;
242
- const connectedAccount = pickConnectedAccount(response, wallet, hadAccountSelection);
243
- if (connectedAccount === null) {
189
+
190
+ const hadAccountSelection = connectedWalletIndex === selectedWalletIndex && accountSelect.value !== '';
191
+ const accounts = await wallet.accounts(config.network);
192
+ connectedWalletIndex = selectedWalletIndex;
193
+ setAccountOptions(accounts, accountSelect.value);
194
+
195
+ if (connectedAccounts.length > 1 && !hadAccountSelection) {
196
unlockButton.textContent = 'Pay with selected address';
197
setStatus('Select an address and continue');
198
return null;
199
}
248
- if (!connectedAccount) {
200
+
201
+ const account = accountByAddress(connectedAccounts, accountSelect.value) ||
202
+ (connectedAccounts.length === 1 ? connectedAccounts[0] : null);
203
+ if (!account) {
204
throw new Error('Connected wallet did not return an account');
205
}
251
- if (Array.isArray(connectedAccount.chains) && !connectedAccount.chains.includes(config.network)) {
206
+ if (Array.isArray(account.chains) && !account.chains.includes(config.network)) {
207
throw new Error(`Connected account does not advertise ${config.network}`);
208
}
254
- return { wallet, account: connectedAccount };
255
- }
256
-
257
- function base64ToBytes(value) {
258
- const binary = atob(value);
259
- const bytes = new Uint8Array(binary.length);
260
- for (let i = 0; i < binary.length; i += 1) {
261
- bytes[i] = binary.charCodeAt(i);
262
- }
263
- return bytes;
264
- }
265
-
266
- function bytesToBase64(bytes) {
267
- let binary = '';
268
- for (let i = 0; i < bytes.length; i += 0x8000) {
269
- binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
270
- }
271
- return btoa(binary);
272
- }
273
-
274
- function transactionFromBase64(value) {
275
- return Transaction.from(base64ToBytes(value));
276
- }
277
-
278
- function suiRpcURL() {
279
- const endpoints = Array.isArray(config.endpoints) ? config.endpoints.filter(Boolean) : [];
280
- const defaults = {
281
- 'sui:mainnet': 'https://sui-rpc.publicnode.com',
282
- 'sui:testnet': 'https://sui-testnet-rpc.publicnode.com',
283
- };
284
- return endpoints[0] || defaults[String(config.network || '').trim().toLowerCase()] || defaults['sui:testnet'];
285
- }
286
-
287
- function transactionDigest(value) {
288
- if (!value || typeof value !== 'object') {
289
- return '';
290
- }
291
- const stack = [value];
292
- while (stack.length > 0) {
293
- const current = stack.pop();
294
- if (!current || typeof current !== 'object') {
295
- continue;
296
- }
297
- for (const [key, nested] of Object.entries(current)) {
298
- if ((key === 'digest' || key === 'transactionDigest') && typeof nested === 'string') {
299
- return nested;
300
- }
301
- if (nested && typeof nested === 'object') {
302
- stack.push(nested);
303
- }
304
- }
305
- }
306
- return '';
307
- }
308
-
309
- function sleep(ms) {
310
- return new Promise((resolve) => setTimeout(resolve, ms));
311
- }
312
-
313
- async function suiRPC(method, params) {
314
- const response = await fetch(suiRpcURL(), {
315
- method: 'POST',
316
- headers: { 'Content-Type': 'application/json' },
317
- body: JSON.stringify({
318
- jsonrpc: '2.0',
319
- id: 1,
320
- method,
321
- params,
322
- }),
323
- });
324
- if (!response.ok) {
325
- throw new Error(`Sui RPC returned ${response.status}`);
326
- }
327
- const body = await response.json();
328
- if (body.error) {
329
- throw new Error(body.error.message || 'Sui RPC error');
330
- }
331
- return body.result;
332
- }
333
-
334
- async function waitForTransaction(value) {
335
- const digest = transactionDigest(value);
336
- if (!digest) {
337
- return;
338
- }
339
-
340
- const deadline = Date.now() + 20000;
341
- let lastError = null;
342
- for (;;) {
343
- let result = null;
344
- try {
345
- result = await suiRPC('sui_getTransactionBlock', [
346
- digest,
347
- { showEffects: true },
348
- ]);
349
- } catch (error) {
350
- lastError = error;
351
- if (Date.now() >= deadline) {
352
- throw lastError;
353
- }
354
- await sleep(1000);
355
- }
356
- if (!result?.digest) {
357
- lastError = new Error('Prepare transaction is not indexed yet');
358
- if (Date.now() >= deadline) {
359
- throw lastError;
360
- }
361
- await sleep(1000);
362
- continue;
363
- }
364
- const status = result.effects?.status?.status;
365
- if (status && status !== 'success') {
366
- throw new Error(result.effects.status.error || 'Prepare transaction failed');
367
- }
368
- return;
369
- }
370
- }
371
-
372
- async function signTransaction(wallet, account, transactionBytes) {
373
- const tx = transactionFromBase64(transactionBytes);
374
- if (wallet.features['sui:signTransaction']) {
375
- return wallet.features['sui:signTransaction'].signTransaction({
376
- transaction: tx,
377
- account,
378
- chain: config.network,
379
- });
380
- }
381
- return wallet.features['sui:signTransactionBlock'].signTransactionBlock({
382
- transactionBlock: tx,
383
- account,
384
- chain: config.network,
385
- });
386
- }
387
-
388
- async function executePrepareTransaction(wallet, account, transactionBytes) {
389
- const tx = transactionFromBase64(transactionBytes);
390
- if (wallet.features['sui:signAndExecuteTransaction']) {
391
- return wallet.features['sui:signAndExecuteTransaction'].signAndExecuteTransaction({
392
- transaction: tx,
393
- account,
394
- chain: config.network,
395
- });
396
- }
397
- if (wallet.features['sui:signAndExecuteTransactionBlock']) {
398
- return wallet.features['sui:signAndExecuteTransactionBlock'].signAndExecuteTransactionBlock({
399
- transactionBlock: tx,
400
- account,
401
- chain: config.network,
402
- });
403
- }
404
- throw new Error('This wallet cannot execute the USDC prepare transaction');
405
- }
406
-
407
- function encodePaymentPayload(payload) {
408
- return bytesToBase64(new TextEncoder().encode(JSON.stringify(payload)));
409
- }
410
-
411
- async function preparePayment(sender) {
412
- const response = await fetch(config.preparePath, {
413
- method: 'POST',
414
- headers: { 'Content-Type': 'application/json' },
415
- body: JSON.stringify({
416
- sender,
417
- method: 'GET',
418
- path: config.protectedPath,
419
- }),
420
- });
421
- if (!response.ok) {
422
- throw new Error(await response.text());
423
- }
424
- return response.json();
209
+ return { wallet, account };
210
}
211
212
async function unlock() {
@@ -443,33 +228,12 @@
228
paymentFrame.hidden = false;
229
viewerTitle.textContent = 'Sui wallet payment';
230
446
- setStatus('Preparing USDC transaction');
447
- const prepared = await preparePayment(account.address);
448
- if (prepared.prepareTransaction) {
449
- setStatus('Preparing object balance in wallet');
450
- const preparedResult = await executePrepareTransaction(wallet, account, prepared.prepareTransaction.transaction);
451
- setStatus('Waiting for prepared balance');
452
- await waitForTransaction(preparedResult);
453
- }
454
-
455
- setStatus('Signing x402 payment');
456
- const signed = await signTransaction(wallet, account, prepared.paymentTransaction.transaction);
457
- const paymentPayload = {
458
- x402Version: prepared.x402Version,
459
- payload: {
460
- signature: signed.signature,
461
- transaction: signed.bytes || signed.transactionBlockBytes || prepared.paymentTransaction.transaction,
462
- },
463
- accepted: prepared.paymentRequirements,
464
- resource: prepared.resource,
465
- };
466
-
467
- setStatus('Settling payment');
468
- const protectedResponse = await fetch(config.protectedPath, {
469
- method: 'GET',
470
- headers: {
471
- 'X-PAYMENT': encodePaymentPayload(paymentPayload),
472
- },
231
+ const protectedResponse = await x402Fetch(config.protectedPath, { method: 'GET' }, {
232
+ wallet,
233
+ account,
234
+ network: config.network,
235
+ preparePath: config.preparePath,
236
+ onStatus: setStatus,
237
});
238
if (!protectedResponse.ok) {
239
throw new Error(await protectedResponse.text());
@@ -494,7 +258,7 @@
258
259
unlockButton.addEventListener('click', unlock);
260
walletSelect.addEventListener('change', () => {
497
- connectedWallet = null;
261
+ connectedWalletIndex = '';
262
connectedAccounts = [];
263
resetAccountSelect();
264
unlockButton.textContent = 'Connect and pay';
@@ -512,10 +276,7 @@
276
});
277
278
refreshWallets();
515
- if (typeof walletsApi.on === 'function') {
516
- walletsApi.on('register', refreshWallets);
517
- walletsApi.on('unregister', refreshWallets);
518
- }
279
+ onSuiWalletChange(refreshWallets);
280
</script>
281
</body>
282
cmd/payment-app/static/photo.html
new
+149
@@ -0,0 +1,149 @@
1
+<!DOCTYPE html>
2
+<html lang="en">
3
+
4
+<head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>{{.PageTitle}}</title>
8
+ <meta name="description" content="{{.PageDescription}}">
9
+ <meta property="og:type" content="website">
10
+ <meta property="og:title" content="{{.PageTitle}}">
11
+ <meta property="og:description" content="{{.PageDescription}}">
12
+ <meta property="og:image" content="{{.OGImage}}">
13
+ <meta property="og:url" content="{{.URL}}">
14
+ <meta name="twitter:card" content="summary_large_image">
15
+ <meta name="twitter:title" content="{{.PageTitle}}">
16
+ <meta name="twitter:description" content="{{.PageDescription}}">
17
+ <meta name="twitter:image" content="{{.OGImage}}">
18
+ <style>
19
+ * {
20
+ box-sizing: border-box;
21
+ }
22
+
23
+ body {
24
+ margin: 0;
25
+ min-height: 100vh;
26
+ display: flex;
27
+ padding: 0;
28
+ background: #f7f8fb;
29
+ color: #182033;
30
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
31
+ }
32
+
33
+ main {
34
+ display: grid;
35
+ width: 100%;
36
+ min-height: 100vh;
37
+ grid-template-rows: minmax(0, 1fr) auto;
38
+ overflow: hidden;
39
+ background: #ffffff;
40
+ }
41
+
42
+ img {
43
+ width: 100%;
44
+ height: 100%;
45
+ min-height: 0;
46
+ display: block;
47
+ object-fit: contain;
48
+ background: #111827;
49
+ }
50
+
51
+ section {
52
+ display: grid;
53
+ gap: 10px;
54
+ padding: 18px;
55
+ border-top: 1px solid #e5eaf0;
56
+ }
57
+
58
+ .eyebrow {
59
+ margin: 0;
60
+ color: #0e7490;
61
+ font-size: 12px;
62
+ font-weight: 800;
63
+ text-transform: uppercase;
64
+ letter-spacing: 0;
65
+ }
66
+
67
+ h1 {
68
+ margin: 0;
69
+ color: #111827;
70
+ font-size: 25px;
71
+ line-height: 1.18;
72
+ }
73
+
74
+ p {
75
+ margin: 0;
76
+ color: #4b5565;
77
+ font-size: 15px;
78
+ line-height: 1.55;
79
+ }
80
+
81
+ dl {
82
+ display: grid;
83
+ grid-template-columns: repeat(3, 1fr);
84
+ gap: 10px;
85
+ margin: 4px 0 0;
86
+ }
87
+
88
+ div {
89
+ min-width: 0;
90
+ }
91
+
92
+ dt {
93
+ color: #667085;
94
+ font-size: 12px;
95
+ font-weight: 700;
96
+ }
97
+
98
+ dd {
99
+ margin: 4px 0 0;
100
+ overflow-wrap: anywhere;
101
+ color: #111827;
102
+ font-size: 13px;
103
+ font-weight: 750;
104
+ }
105
+
106
+ @media (max-width: 640px) {
107
+ section {
108
+ padding: 14px;
109
+ }
110
+
111
+ dl {
112
+ grid-template-columns: 1fr;
113
+ }
114
+ }
115
+ </style>
116
+</head>
117
+
118
+<body>
119
+ <main>
120
+ <img src="{{.PhotoURL}}" alt="Unlocked protected image">
121
+ <section>
122
+ <p class="eyebrow">Payment complete</p>
123
+ <h1>Image unlocked</h1>
124
+ <p>The protected image is available after the {{.Amount}} x402 settlement.</p>
125
+ <dl>
126
+ <div>
127
+ <dt>Amount</dt>
128
+ <dd>{{.Amount}}</dd>
129
+ </div>
130
+ <div>
131
+ <dt>Network</dt>
132
+ <dd>{{.NetworkName}}</dd>
133
+ </div>
134
+ <div>
135
+ <dt>Recipient</dt>
136
+ <dd>{{.RecipientAddress}}</dd>
137
+ </div>
138
+ {{if .TransactionID}}
139
+ <div>
140
+ <dt>Transaction</dt>
141
+ <dd>{{.TransactionID}}</dd>
142
+ </div>
143
+ {{end}}
144
+ </dl>
145
+ </section>
146
+ </main>
147
+</body>
148
+
149
+</html>
cmd/portal-tunnel/README.md
+61
-255
@@ -1,65 +1,44 @@
1
# Portal CLI
2
3
-`cmd/portal-tunnel` builds the `portal` CLI. It connects local services to
4
-Portal relays without requiring inbound firewall rules, port forwarding, or
5
-manual DNS setup.
3
+`cmd/portal-tunnel` builds the `portal` CLI. It exposes local services through
4
+Portal relays without inbound firewall rules, port forwarding, or manual DNS
5
+setup.
6
7
-Portal's default model is intentionally simple:
7
+The relay owns transport, lease registration, routing, and relay policy. The
8
+tunnel process owns local proxy behavior, routed HTTP policy, x402 route
9
+payments, and tenant TLS termination for the default HTTPS stream path.
10
9
-- The relay owns transport, lease registration, routing, and relay policy.
10
-- The tunnel process owns the exposed endpoint behavior.
11
-- In the default HTTPS stream path, tenant TLS terminates in the tunnel process,
12
- not at the relay.
13
-- In routed HTTP mode, the tunnel process runs the HTTP reverse proxy. The relay
14
- is still not an HTTP proxy.
15
-- In raw TCP and UDP modes, the relay allocates public transport endpoints and
16
- forwards traffic to the tunnel process.
11
+## Quick Start
12
18
-## Install
19
-
20
-Install directly from the official GitHub release assets:
13
+Install from GitHub release assets:
14
15
```bash
16
curl -fsSL https://github.com/gosuda/portal-tunnel/releases/latest/download/install.sh | bash
17
portal expose 3000
25
-portal list
18
```
19
20
```powershell
21
$ProgressPreference = 'SilentlyContinue'
22
irm https://github.com/gosuda/portal-tunnel/releases/latest/download/install.ps1 | iex
23
portal expose 3000
32
-portal list
24
```
25
35
-If your relay publishes its own installer, use that relay instead:
26
+If a relay publishes its own installer:
27
28
```bash
29
curl -sSL https://portal.example.com/api/install.sh | bash
30
portal expose 3000 --relays https://portal.example.com --discovery=false
31
```
32
42
-```powershell
43
-$ProgressPreference = 'SilentlyContinue'
44
-irm https://portal.example.com/api/install.ps1 | iex
45
-portal expose 3000 --relays https://portal.example.com --discovery=false
46
-```
47
-
48
-## Choosing A Mode
33
+## Modes
34
50
-Use the default stream mode for most local web apps:
35
+Default HTTPS stream for most local web apps:
36
37
```text
38
portal expose 3000 --name myapp
39
```
40
56
-This publishes `myapp.<relay-root-host>` as HTTPS. The relay routes by SNI and
57
-bridges the connection to the tunnel process. The tunnel process performs the
58
-tenant TLS handshake locally and then proxies the byte stream to
59
-`127.0.0.1:3000`.
60
-
61
-Use routed HTTP mode when one public URL should mount multiple local HTTP
62
-services:
41
+Routed HTTP when one public URL should mount multiple local HTTP upstreams:
42
43
```text
44
portal expose --name myapp \
@@ -67,105 +46,51 @@ portal expose --name myapp \
46
--http-route /=http://127.0.0.1:5173
47
```
48
70
-This is a tunnel-controlled HTTP reverse proxy. The relay still only transports
71
-connections. Because the tunnel process parses HTTP in this mode, this is the
72
-right mode for HTTP-specific behavior such as path routing, response header
73
-policy, redirect rewriting, and cookie path remapping.
74
-
75
-Use dedicated raw TCP mode for non-HTTP services that need a public TCP port:
49
+Paid routed HTTP with Sui USDC x402:
50
51
```text
78
-portal expose localhost:25565 --name minecraft --tcp
52
+portal expose --name paid-app \
53
+ --http-route "/paid=http://127.0.0.1:3001 GET:0.01" \
54
+ --http-route /=http://127.0.0.1:5173 \
55
+ --x402-pay-to 0x...
56
```
57
81
-The relay allocates a TCP port from its configured port range and bridges raw
82
-TCP to the local target. This path does not add TLS; use application-level
83
-encryption when the protocol needs confidentiality.
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.
62
85
-Use UDP mode when the service needs a public UDP port:
63
+Raw TCP and UDP:
64
65
```text
66
+portal expose localhost:25565 --name minecraft --tcp
67
portal expose localhost:8080 --udp --udp-addr localhost:19132 --name game
68
```
69
91
-The primary target still receives stream traffic. UDP datagrams are forwarded to
92
-`--udp-addr`; when omitted, UDP uses the primary target.
93
-
94
-## Relay And SEO Boundaries
95
-
96
-The relay cannot safely inject HTTP headers, `robots.txt`, `noindex`, or content
97
-policy into the default passthrough stream path. It does not own the HTTP
98
-response body, and it is not supposed to terminate tenant TLS.
99
-
100
-If a relay is used as a public multi-tenant service, do not put arbitrary user
101
-tunnels under a brand domain that also carries first-party SEO value. Use a
102
-separate tunnel domain for shared wildcard leases, and keep brand, docs, admin,
103
-and product pages on first-party hosts.
104
-
105
-Routed HTTP mode can enforce HTTP policy only inside cooperating tunnel
106
-processes. It is useful for product features, but it is not a substitute for
107
-domain separation because users can still choose the default passthrough path.
108
-
70
## Commands
71
111
-### `portal expose [flags] <target>`
112
-
113
-Expose one local target through the default stream path.
114
-
72
```text
116
-portal expose 3000
117
-portal expose localhost:8080 --name myapp
118
-portal expose http://127.0.0.1:8080 --name local-http
73
+portal expose [flags] <target>
74
+portal expose [flags] --http-route "PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]" [...]
75
+portal list [flags]
76
+portal agent run [flags]
77
+portal agent dashboard [flags]
78
+portal agent stop [flags]
79
+portal agent restart [flags]
80
+portal update
81
+portal version
82
```
83
121
-`<target>` accepts:
122
-
123
-- a bare port, such as `3000`
124
-- a `host:port`
125
-- an `http://host:port` or `https://host:port` URL
126
-
127
-Bare ports resolve to `127.0.0.1:<port>`. URL inputs are accepted for address
128
-parsing only; paths, queries, and fragments are not supported.
129
-
130
-Instead of `<target>`, repeat `--http-route PATH=UPSTREAM` to run routed HTTP
131
-mode:
132
-
133
-```text
134
-portal expose --name myapp \
135
- --http-route /api=http://127.0.0.1:3001 \
136
- --http-route /=http://127.0.0.1:5173
137
-```
138
-
139
-Route matching is longest-prefix-first. A route like
140
-`/api=http://127.0.0.1:3001` matches `/api/*` and strips the `/api` prefix before
141
-proxying to the upstream.
142
-
143
-Routed HTTP mode automatically:
144
-
145
-- forwards `X-Forwarded-*`
146
-- rewrites matching upstream `Location` redirects back to the public route path
147
-- strips loopback cookie domains
148
-- remaps cookie paths to the mounted route prefix
149
-
150
-Mode constraints:
151
-
152
-- `<target>` cannot be combined with `--http-route`.
153
-- `--http-route` cannot be combined with `--udp`.
154
-- Multi-hop currently supports only the default SNI TLS stream transport.
155
-- `--multi-hop` cannot be combined with automatic `--multi-hop-depth`.
156
-- `--x402-amount` applies only to routed HTTP prefixes and requires a
157
- tunnel-owned `--x402-pay-to`.
158
-
159
-Common flags:
84
+Common `portal expose` flags:
85
86
```text
87
--name Public hostname prefix; auto-generated when omitted
88
--relays Additional relay API URLs, comma-separated
89
--discovery Include registry relays and relay discovery expansion
165
---max-active-relays Maximum auto-selected relays; explicit relays are always included
90
+--max-active-relays Maximum auto-selected relays
91
--multi-hop Ordered multi-hop relay API URLs, comma-separated
92
--multi-hop-depth Automatically select one multi-hop route with this hop count
168
---ban-mitm Ban relay when the TLS self-probe detects termination
93
+--ban-mitm Ban relay when the MITM self-probe detects termination
94
--identity-path Identity JSON file path; created automatically when missing
95
--identity-json Identity JSON payload; overrides --identity-path when set
96
--description Service description metadata
@@ -173,44 +98,17 @@ Common flags:
98
--thumbnail Service thumbnail URL metadata
99
--owner Service owner metadata
100
--hide Hide service from relay listing screens
101
+--http-route HTTP route mapping in PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT] form
102
--x402-pay-to Sui USDC payment recipient address for this tunnel
177
---x402-amount Sui USDC x402 amount mapping in [METHOD[,METHOD...]:]PATH=ATOMIC_AMOUNT form; repeatable
178
---http-route HTTP route mapping in PATH=UPSTREAM form; repeatable
103
--tcp Request a dedicated raw TCP port on the relay
180
---udp Enable public UDP relay in addition to the default stream path
181
---udp-addr Local UDP target; defaults to the primary target when --udp is enabled
104
+--udp Enable public UDP relay
105
+--udp-addr Local UDP target
106
--metrics-addr Optional host:port for Prometheus /metrics
107
```
108
185
-Custom relay and metadata example:
186
-
187
-```text
188
-portal expose localhost:8080 \
189
- --name myapp \
190
- --identity-path ~/.config/portal/myapp.identity.json \
191
- --relays https://portal.example.com \
192
- --discovery=false \
193
- --description "Service description" \
194
- --tags tag1,tag2 \
195
- --thumbnail https://example.com/thumb.png \
196
- --owner "Portal Operator"
197
-```
198
-
199
-### `portal list [flags]`
200
-
201
-Print the relay URLs that the CLI will use for the current invocation.
202
-
203
-```text
204
-portal list
205
-portal list --relays https://portal.example.com --default-relays=false
206
-```
207
-
208
-`portal list` resolves the registry seed list plus explicit relays. Unlike
209
-`portal expose`, it does not run the runtime relay discovery expansion loop.
210
-
211
-### `portal agent run [flags]`
109
+## Agent
110
213
-Run Portal as a managed long-lived tunnel agent.
111
+Use the agent for durable multi-tunnel operation from one config file:
112
113
```text
114
portal agent run
@@ -219,120 +117,28 @@ portal agent stop
117
portal agent restart
118
```
119
222
-The agent service owns multiple tunnel definitions from one config file. The
223
-local control API binds to loopback and is authenticated with a token stored in
224
-the agent state directory.
120
+The dashboard can edit basic tunnel settings, relays, and multi-hop routes. Add
121
+Tunnel opens a small form for name, target or HTTP routes, relays, discovery,
122
+and max active relays. After creation, routed HTTP paths, route-level x402
123
+amounts, and discovery mode are read-only in the Settings pane. Edit
124
+`http_routes`, `x402_pay_to`, or `discovery` in TOML, then restart the agent or
125
+tunnel to change them.
126
226
-For the full agent workflow, control API, dashboard behavior, and wallet status
227
-auth details, see [Portal Agent](../../docs/src/routes/portal-agent/+page.md)
228
-and [Wallet and ENS](../../docs/src/routes/wallet-and-ens/+page.md).
127
+## Constraints
128
230
-Useful commands:
231
-
232
-- `portal agent run` reads the platform default config path, installs or updates
233
- the OS service, starts it in the background, and exits after the agent is ready.
234
-- `portal agent run --config config.toml --foreground` runs the agent in the
235
- current terminal and opens the dashboard when the terminal is interactive.
236
-- `portal agent dashboard` attaches to a running agent and opens the local TUI
237
- for tunnels, relays, multi-hop routes, and editable tunnel settings.
238
-- `portal agent stop` asks the local agent to shut down, then disables or stops
239
- the OS service so intentional shutdown is not immediately restarted.
240
-- `portal agent restart` stops the running agent if present, installs or updates
241
- the service from the existing config, and starts it again.
242
-
243
-`portal agent run`, `stop`, and `restart` require an existing config file.
244
-`portal agent dashboard` can attach with only the default state directory or an
245
-explicit `--state-dir`.
246
-
247
-Default paths:
248
-
249
-| OS | Config | Default identity |
250
-|----|--------|------------------|
251
-| Linux user | `$XDG_CONFIG_HOME/portal-tunnel/agent/config.toml` or `~/.config/portal-tunnel/agent/config.toml` | `$XDG_DATA_HOME/portal-tunnel/agent/identity.json` or `~/.local/share/portal-tunnel/agent/identity.json` |
252
-| Linux root | `/etc/portal-tunnel/agent/config.toml` | `/var/lib/portal-tunnel/agent/identity.json` |
253
-| macOS user | `~/Library/Application Support/Portal Tunnel/Agent/config.toml` | `~/Library/Application Support/Portal Tunnel/Agent/identity.json` |
254
-| macOS root | `/Library/Application Support/Portal Tunnel/Agent/config.toml` | `/Library/Application Support/Portal Tunnel/Agent/identity.json` |
255
-| Windows | `%ProgramData%\Portal Tunnel\Agent\config.toml` | `%ProgramData%\Portal Tunnel\Agent\identity.json` |
256
-
257
-Example `config.toml`:
258
-
259
-```toml
260
-[agent]
261
-control_addr = "127.0.0.1:4018"
262
-service_name = "portal-agent"
263
-
264
-[[tunnels]]
265
-id = "web"
266
-name = "myapp"
267
-target = "127.0.0.1:3000"
268
-relays = ["https://portal.example.com"]
269
-discovery = false
270
-description = "Managed web tunnel"
271
-tags = ["web"]
272
-
273
-[[tunnels]]
274
-id = "frontend"
275
-name = "myapp-http"
276
-relays = ["https://portal.example.com"]
277
-discovery = false
278
-x402_pay_to = "0x..."
279
-http_routes = [
280
- { prefix = "/api", upstream = "http://127.0.0.1:3001", methods = ["GET"], amount = "100000" },
281
- { prefix = "/", upstream = "http://127.0.0.1:5173" },
282
-]
283
-```
284
-
285
-## Install Behavior
286
-
287
-- `install.sh` installs the downloaded binary as `portal` and adds the install
288
- directory to the user's shell profile when it is not already on `PATH`.
289
-- `install.ps1` installs `portal.exe` for the current Windows user and updates
290
- the user `PATH`.
291
-- The installer does not write a config file.
292
-- `portal expose 3000` works after install because discovery is enabled by
293
- default.
294
-- To target only a specific relay, use
295
- `--relays https://portal.example.com --discovery=false`.
296
-
297
-## Operational Notes
298
-
299
-- `portal expose` loads or creates the signing identity at `identity.json` by
300
- default.
301
-- Reusing the same `--identity-path` keeps the same tunnel address across runs.
302
-- Use different `--identity-path` values when you want separate local identities.
303
-- Relay publishes each default stream service at `<name>.<portal-root-host>`.
304
-- Multiple relay URLs are registered independently. Each relay gets its own
305
- lease registration and public URL.
306
-- The tunnel consumes one aggregate SDK listener; the CLI does not run per-relay
307
- listener loops itself.
308
-- Relay startup and reconnect failures are retried independently in the
309
- background. One unhealthy relay does not stop healthy relays from serving.
310
-- The tunnel starts once relay URLs pass local validation. Remote compatibility
311
- checks, lease registration, and reconnects continue in the background until
312
- each relay becomes ready.
313
-- With discovery enabled, the tunnel uses the public registry as discovery seed
314
- input and can expand through relay discovery.
315
-- Explicit `--relays` values are always included separately from the
316
- auto-selected relay pool.
317
-- With `--discovery=false`, only explicit relay URLs are used.
318
-- Published public URLs appear only for relays that register successfully.
319
-- Explicit relay listeners retry indefinitely. Auto-selected discovery relays
320
- are dropped from the active set after their retry budget is exhausted.
321
-- Tenant TLS is provisioned automatically through the relay keyless signer. The
322
- SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
323
-- `portal expose` logs MITM self-probe failures by default. Use `--ban-mitm`
324
- when suspected TLS termination should ban the relay automatically.
325
-- When the local stream target is unreachable, the tunnel returns an HTTP 503
326
- page to browser-style clients.
327
-- `--tcp` requires the relay to have TCP port transport enabled and a valid
328
- `MIN_PORT`/`MAX_PORT` range.
329
-- `--udp` requires the relay to have UDP transport enabled and a valid
330
- `MIN_PORT`/`MAX_PORT` range.
129
+- A positional `<target>` cannot be combined with `--http-route`.
130
+- `--http-route` cannot be combined with `--udp`.
131
+- Route payment amounts are USDC values such as `0.01`, are part of
132
+ `--http-route`, and require `--x402-pay-to`.
133
+- `--multi-hop` cannot be combined with `--multi-hop-depth`.
134
+- Multi-hop currently supports only the default SNI TLS stream transport.
135
+- `--tcp` and `--udp` require matching relay transport support.
136
332
-## Compatibility Notes
137
+## More Docs
138
334
-- Use `portal expose ...` explicitly; bare `portal [flags]` is not accepted.
335
-- Runtime `APP_*`, `RELAYS`, and `DEFAULT_RELAYS` environment variable fallbacks
336
- are not used.
337
-- Pass either a positional local target or repeat `--http-route`; do not use
338
- both in the same tunnel.
139
+- [CLI Reference](../../docs/src/routes/cli-reference/+page.md)
140
+- [Concepts](../../docs/src/routes/concepts/+page.md)
141
+- [Configuration Reference](../../docs/src/routes/configuration/+page.md)
142
+- [Portal Agent](../../docs/src/routes/portal-agent/+page.md)
143
+- [Self Hosting](../../docs/src/routes/self-hosting/+page.md)
144
+- [Wallet and ENS](../../docs/src/routes/wallet-and-ens/+page.md)
cmd/portal-tunnel/agent/dashboard.go
+463
-62
@@ -42,6 +42,7 @@ const (
42
agentDashboardActionClearHop
43
agentDashboardActionApplySettings
44
agentDashboardActionFocusSettingsField
45
+ agentDashboardActionFocusAddTunnelField
46
agentDashboardActionOpenTunnelURL
47
)
48
@@ -55,6 +56,17 @@ const (
56
agentDashboardPaneCount
57
)
58
59
+const (
60
+ agentDashboardAddFieldName = iota
61
+ agentDashboardAddFieldTarget
62
+ agentDashboardAddFieldHTTPRoutes
63
+ agentDashboardAddFieldX402PayTo
64
+ agentDashboardAddFieldRelays
65
+ agentDashboardAddFieldDiscovery
66
+ agentDashboardAddFieldMaxRelays
67
+ agentDashboardAddFieldCount
68
+)
69
+
70
const (
71
agentDashboardSettingsFieldMaxActiveRelays = iota
72
agentDashboardSettingsFieldDescription
@@ -87,8 +99,15 @@ type agentDashboardModel struct {
99
routeDraft []string
100
draftTunnelID string
101
90
- addingTunnel bool
91
- input textinput.Model
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
111
112
settingsEditTunnelID string
113
settingsFocus int
@@ -150,15 +169,16 @@ var (
169
)
170
171
func RunDashboard(configPath, stateDir string) error {
153
- input := newAgentDashboardTextInput()
154
- input.Prompt = "Name port: "
155
- input.Placeholder = "myname 3000"
156
- input.Width = agentDashboardTunnelInputMaxWidth
157
-
172
model := agentDashboardModel{
173
configPath: configPath,
174
stateDir: stateDir,
161
- input: input,
175
+ addName: newAgentDashboardInlineInput("myapp"),
176
+ addTarget: newAgentDashboardInlineInput("3000"),
177
+ addHTTPRoutes: newAgentDashboardInlineInput("/paid=3001 GET:0.01; /=5173"),
178
+ addX402PayTo: newAgentDashboardInlineInput("0x..."),
179
+ addRelays: newAgentDashboardInlineInput("https://portal.example.com"),
180
+ addDiscovery: newAgentDashboardInlineInput("true"),
181
+ addMaxRelays: newAgentDashboardInlineInput("3"),
182
settingsMaxRelays: newAgentDashboardInlineInput("3"),
183
metadataDescription: newAgentDashboardInlineInput("description"),
184
metadataTags: newAgentDashboardInlineInput("api,staging"),
@@ -166,6 +186,7 @@ func RunDashboard(configPath, stateDir string) error {
186
metadataThumbnail: newAgentDashboardInlineInput("https://..."),
187
metadataHide: newAgentDashboardInlineInput("true or false"),
188
}
189
+ model.resetAddTunnelForm()
190
model.resizeInputs(0)
191
192
_, err := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run()
@@ -235,8 +256,6 @@ func (m agentDashboardModel) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
256
if m.activePane == agentDashboardPaneTunnels {
257
if m.addingTunnel {
258
m.cancelTunnelInput()
238
- } else {
239
- m.input.Reset()
259
}
260
} else {
261
m.setActivePane(agentDashboardPaneTunnels)
@@ -266,6 +285,27 @@ func (m agentDashboardModel) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
285
}
286
287
func (m agentDashboardModel) updateTunnelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
288
+ if m.addingTunnel {
289
+ switch msg.String() {
290
+ case "tab", "down":
291
+ m.focusAddTunnelField(m.addFocus + 1)
292
+ return m, nil
293
+ case "shift+tab", "up":
294
+ m.focusAddTunnelField(m.addFocus - 1)
295
+ return m, nil
296
+ case "enter":
297
+ return m.addTunnelFromInput()
298
+ }
299
+
300
+ input := m.focusedAddTunnelInput()
301
+ if input == nil {
302
+ return m, nil
303
+ }
304
+ var cmd tea.Cmd
305
+ *input, cmd = input.Update(msg)
306
+ return m, cmd
307
+ }
308
+
309
switch msg.String() {
310
case "up":
311
m.selectTunnelOffset(-1)
@@ -274,20 +314,9 @@ func (m agentDashboardModel) updateTunnelKeys(msg tea.KeyMsg) (tea.Model, tea.Cm
314
m.selectTunnelOffset(1)
315
return m, nil
316
case "delete":
277
- if !m.addingTunnel {
278
- return m.deleteTunnel("")
279
- }
280
- case "enter":
281
- if m.addingTunnel {
282
- return m.addTunnelFromInput()
283
- }
317
+ return m.deleteTunnel("")
318
}
285
- if !m.addingTunnel {
286
- return m, nil
287
- }
288
- var cmd tea.Cmd
289
- m.input, cmd = m.input.Update(msg)
290
- return m, cmd
319
+ return m, nil
320
}
321
322
func (m agentDashboardModel) updateRelayKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -361,6 +390,15 @@ func (m agentDashboardModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd)
390
m.focusSettingsField(region.field)
391
return m, nil
392
}
393
+ if region.action == agentDashboardActionFocusAddTunnelField {
394
+ m.setActivePane(agentDashboardPaneTunnels)
395
+ if !m.addingTunnel {
396
+ m.addingTunnel = true
397
+ m.resetAddTunnelForm()
398
+ }
399
+ m.focusAddTunnelField(region.field)
400
+ return m, nil
401
+ }
402
return m.runAction(region.action, region.tunnel, region.relay)
403
}
404
}
@@ -479,12 +517,12 @@ func (m *agentDashboardModel) setActivePane(pane agentDashboardPane) {
517
pane = 0
518
}
519
m.activePane = pane
482
- m.input.Blur()
520
+ m.blurAddTunnelInputs()
521
m.blurSettingsInputs()
522
switch pane {
523
case agentDashboardPaneTunnels:
524
if m.addingTunnel {
487
- _ = m.input.Focus()
525
+ m.focusAddTunnelField(m.addFocus)
526
}
527
case agentDashboardPaneSettings:
528
m.ensureSelectedSettingsDraft()
@@ -679,6 +717,75 @@ func agentDashboardRelayKey(tunnelID, relayURL string) string {
717
return tunnelID + "\x00" + relayURL
718
}
719
720
+func (m *agentDashboardModel) focusAddTunnelField(field int) {
721
+ fieldCount := agentDashboardAddFieldCount
722
+ if field < 0 {
723
+ field = fieldCount - 1
724
+ }
725
+ if field >= fieldCount {
726
+ field = 0
727
+ }
728
+ m.blurSettingsInputs()
729
+ m.addFocus = field
730
+ m.blurAddTunnelInputs()
731
+ if input := m.focusedAddTunnelInput(); input != nil {
732
+ _ = input.Focus()
733
+ }
734
+}
735
+
736
+func (m *agentDashboardModel) focusedAddTunnelInput() *textinput.Model {
737
+ switch m.addFocus {
738
+ case agentDashboardAddFieldName:
739
+ return &m.addName
740
+ case agentDashboardAddFieldTarget:
741
+ return &m.addTarget
742
+ case agentDashboardAddFieldHTTPRoutes:
743
+ return &m.addHTTPRoutes
744
+ case agentDashboardAddFieldX402PayTo:
745
+ return &m.addX402PayTo
746
+ case agentDashboardAddFieldRelays:
747
+ return &m.addRelays
748
+ case agentDashboardAddFieldDiscovery:
749
+ return &m.addDiscovery
750
+ case agentDashboardAddFieldMaxRelays:
751
+ return &m.addMaxRelays
752
+ default:
753
+ return nil
754
+ }
755
+}
756
+
757
+func (m *agentDashboardModel) blurAddTunnelInputs() {
758
+ for _, input := range []*textinput.Model{
759
+ &m.addName,
760
+ &m.addTarget,
761
+ &m.addHTTPRoutes,
762
+ &m.addX402PayTo,
763
+ &m.addRelays,
764
+ &m.addDiscovery,
765
+ &m.addMaxRelays,
766
+ } {
767
+ input.Blur()
768
+ }
769
+}
770
+
771
+func (m *agentDashboardModel) resetAddTunnelForm() {
772
+ for _, input := range []*textinput.Model{
773
+ &m.addName,
774
+ &m.addTarget,
775
+ &m.addHTTPRoutes,
776
+ &m.addX402PayTo,
777
+ &m.addRelays,
778
+ } {
779
+ input.Reset()
780
+ }
781
+ m.addDiscovery.SetValue("true")
782
+ m.addMaxRelays.SetValue("3")
783
+ m.addDiscovery.CursorEnd()
784
+ m.addMaxRelays.CursorEnd()
785
+ m.addFocus = agentDashboardAddFieldName
786
+ m.blurAddTunnelInputs()
787
+}
788
+
789
func (m agentDashboardModel) updateSettingsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
790
switch msg.String() {
791
case "tab", "down":
@@ -708,7 +815,7 @@ func (m *agentDashboardModel) focusSettingsField(field int) {
815
if field >= fieldCount {
816
field = 0
817
}
711
- m.input.Blur()
818
+ m.blurAddTunnelInputs()
819
m.settingsFocus = field
820
for _, input := range []*textinput.Model{
821
&m.settingsMaxRelays,
@@ -771,7 +878,7 @@ func (m *agentDashboardModel) clearSettingsDraft() {
878
}
879
m.blurSettingsInputs()
880
if m.activePane == agentDashboardPaneTunnels && m.addingTunnel {
774
- _ = m.input.Focus()
881
+ m.focusAddTunnelField(m.addFocus)
882
}
883
}
884
@@ -780,11 +887,15 @@ func (m *agentDashboardModel) resizeInputs(width int) {
887
width = 88
888
}
889
contentWidth, _, _ := agentDashboardColumnWidths(width)
783
- availableWidth := contentWidth - lipgloss.Width(m.input.Prompt)
784
- m.input.Width = max(1, min(agentDashboardTunnelInputMaxWidth, availableWidth))
785
-
890
settingsWidth := max(1, min(agentDashboardTunnelInputMaxWidth, contentWidth-13))
891
for _, input := range []*textinput.Model{
892
+ &m.addName,
893
+ &m.addTarget,
894
+ &m.addHTTPRoutes,
895
+ &m.addX402PayTo,
896
+ &m.addRelays,
897
+ &m.addDiscovery,
898
+ &m.addMaxRelays,
899
&m.settingsMaxRelays,
900
&m.metadataDescription,
901
&m.metadataTags,
@@ -829,46 +940,196 @@ func (m *agentDashboardModel) loadSettingsDraft(tunnel types.AgentTunnelStatus)
940
}
941
942
func (m agentDashboardModel) addTunnelFromInput() (tea.Model, tea.Cmd) {
832
- value := strings.TrimSpace(m.input.Value())
833
- if value == "" {
834
- m.addingTunnel = true
835
- _ = m.input.Focus()
836
- return m, nil
837
- }
838
- fields := strings.Fields(value)
839
- if len(fields) < 2 {
840
- m.err = fmt.Errorf("use: name port")
841
- return m, nil
842
- }
843
- name := strings.Join(fields[:len(fields)-1], " ")
844
- if agentTunnelID(name) == "" {
845
- m.err = fmt.Errorf("tunnel name is required")
846
- return m, nil
847
- }
848
- targetInput := fields[len(fields)-1]
849
- target, err := utils.NormalizeLoopbackTarget(targetInput)
850
- if err != nil || target == "" {
851
- m.err = fmt.Errorf("invalid target %q", targetInput)
943
+ req, err := m.addTunnelRequest()
944
+ if err != nil {
945
+ m.err = err
946
return m, nil
947
}
948
949
m.err = nil
856
- m.input.Reset()
950
m.addingTunnel = false
858
- m.input.Blur()
951
+ m.resetAddTunnelForm()
952
return m, agentDashboardRun(func(ctx context.Context) error {
860
- return AddTunnel(ctx, m.stateDir, types.AgentTunnelRequest{
861
- Name: name,
862
- TargetAddr: target,
863
- })
953
+ return AddTunnel(ctx, m.stateDir, req)
954
})
955
}
956
957
+func (m agentDashboardModel) addTunnelRequest() (types.AgentTunnelRequest, error) {
958
+ name := strings.TrimSpace(m.addName.Value())
959
+ if agentTunnelID(name) == "" {
960
+ return types.AgentTunnelRequest{}, fmt.Errorf("tunnel name is required")
961
+ }
962
+
963
+ targetInput := strings.TrimSpace(m.addTarget.Value())
964
+ routesInput := strings.TrimSpace(m.addHTTPRoutes.Value())
965
+ if targetInput != "" && routesInput != "" {
966
+ return types.AgentTunnelRequest{}, fmt.Errorf("target cannot be combined with routes")
967
+ }
968
+ if targetInput == "" && routesInput == "" {
969
+ return types.AgentTunnelRequest{}, fmt.Errorf("target or routes is required")
970
+ }
971
+
972
+ var target string
973
+ if targetInput != "" {
974
+ var err error
975
+ target, err = utils.NormalizeLoopbackTarget(targetInput)
976
+ if err != nil || target == "" {
977
+ return types.AgentTunnelRequest{}, fmt.Errorf("invalid target %q", targetInput)
978
+ }
979
+ }
980
+
981
+ routes, err := agentDashboardParseAddHTTPRoutes(routesInput)
982
+ if err != nil {
983
+ return types.AgentTunnelRequest{}, err
984
+ }
985
+ payTo := strings.TrimSpace(m.addX402PayTo.Value())
986
+ hasPaidRoute := false
987
+ for _, route := range routes {
988
+ if strings.TrimSpace(route.Amount) != "" {
989
+ hasPaidRoute = true
990
+ break
991
+ }
992
+ }
993
+ if hasPaidRoute && payTo == "" {
994
+ return types.AgentTunnelRequest{}, fmt.Errorf("paid routes require X402 Pay To")
995
+ }
996
+ if len(routes) == 0 && payTo != "" {
997
+ return types.AgentTunnelRequest{}, fmt.Errorf("X402 Pay To requires routes")
998
+ }
999
+
1000
+ discoveryRaw := strings.TrimSpace(m.addDiscovery.Value())
1001
+ if discoveryRaw == "" {
1002
+ discoveryRaw = "true"
1003
+ }
1004
+ discovery, err := strconv.ParseBool(discoveryRaw)
1005
+ if err != nil {
1006
+ return types.AgentTunnelRequest{}, fmt.Errorf("discovery must be true or false")
1007
+ }
1008
+
1009
+ maxRelaysRaw := strings.TrimSpace(m.addMaxRelays.Value())
1010
+ if maxRelaysRaw == "" {
1011
+ maxRelaysRaw = "3"
1012
+ }
1013
+ maxRelays, err := strconv.Atoi(maxRelaysRaw)
1014
+ if err != nil || maxRelays <= 0 {
1015
+ return types.AgentTunnelRequest{}, fmt.Errorf("max relays must be a positive integer")
1016
+ }
1017
+
1018
+ return types.AgentTunnelRequest{
1019
+ Name: name,
1020
+ TargetAddr: target,
1021
+ HTTPRoutes: routes,
1022
+ RelayURLs: utils.SplitCSV(m.addRelays.Value()),
1023
+ Discovery: &discovery,
1024
+ MaxActiveRelays: maxRelays,
1025
+ X402PayTo: payTo,
1026
+ }, nil
1027
+}
1028
+
1029
+func agentDashboardParseAddHTTPRoutes(value string) ([]types.AgentHTTPRoute, error) {
1030
+ value = strings.TrimSpace(value)
1031
+ if value == "" {
1032
+ return nil, nil
1033
+ }
1034
+ var routes []types.AgentHTTPRoute
1035
+ seen := make(map[string]struct{})
1036
+
1037
+ for _, rawSegment := range strings.Split(value, ";") {
1038
+ segment := strings.TrimSpace(rawSegment)
1039
+ if segment == "" {
1040
+ continue
1041
+ }
1042
+ key, rest, ok := strings.Cut(segment, "=")
1043
+ if !ok {
1044
+ return nil, fmt.Errorf("route %q must be PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]", segment)
1045
+ }
1046
+ key = strings.TrimSpace(key)
1047
+ rest = strings.TrimSpace(rest)
1048
+ if strings.EqualFold(key, "payto") || strings.EqualFold(key, "x402_pay_to") {
1049
+ return nil, fmt.Errorf("use the X402 Pay To field instead of %q in routes", key)
1050
+ }
1051
+ if key == "" {
1052
+ return nil, fmt.Errorf("route path is required")
1053
+ }
1054
+ if !strings.HasPrefix(key, "/") {
1055
+ return nil, fmt.Errorf("route path %q must start with /", key)
1056
+ }
1057
+
1058
+ parts := strings.Fields(rest)
1059
+ if len(parts) == 0 || len(parts) > 2 {
1060
+ return nil, fmt.Errorf("route %q must be PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]", segment)
1061
+ }
1062
+ if parts[0] == "" {
1063
+ return nil, fmt.Errorf("route %q upstream is required", key)
1064
+ }
1065
+
1066
+ prefix := utils.NormalizeURLPath(key)
1067
+ if _, ok := seen[prefix]; ok {
1068
+ return nil, fmt.Errorf("duplicate route path %q", prefix)
1069
+ }
1070
+ seen[prefix] = struct{}{}
1071
+
1072
+ route := types.AgentHTTPRoute{
1073
+ Prefix: prefix,
1074
+ Upstream: parts[0],
1075
+ }
1076
+ if len(parts) == 2 {
1077
+ methods, amount, err := agentDashboardParseAddRoutePayment(parts[1])
1078
+ if err != nil {
1079
+ return nil, fmt.Errorf("route %q: %w", prefix, err)
1080
+ }
1081
+ route.Methods = methods
1082
+ route.Amount = amount
1083
+ }
1084
+ routes = append(routes, route)
1085
+ }
1086
+
1087
+ if len(routes) == 0 {
1088
+ return nil, fmt.Errorf("at least one http route is required")
1089
+ }
1090
+ return routes, nil
1091
+}
1092
+
1093
+func agentDashboardParseAddRoutePayment(value string) ([]string, string, error) {
1094
+ value = strings.TrimSpace(value)
1095
+ if value == "" {
1096
+ return nil, "", fmt.Errorf("payment amount is required")
1097
+ }
1098
+ methodPart, amount, hasMethods := strings.Cut(value, ":")
1099
+ if !hasMethods {
1100
+ amount = value
1101
+ methodPart = ""
1102
+ }
1103
+ amount = strings.TrimSpace(amount)
1104
+ if amount == "" {
1105
+ return nil, "", fmt.Errorf("payment amount is required")
1106
+ }
1107
+ if !hasMethods {
1108
+ return nil, amount, nil
1109
+ }
1110
+
1111
+ methods := []string(nil)
1112
+ for _, rawMethod := range strings.Split(methodPart, ",") {
1113
+ method := strings.ToUpper(strings.TrimSpace(rawMethod))
1114
+ if method == "" {
1115
+ return nil, "", fmt.Errorf("payment method is required")
1116
+ }
1117
+ if !slices.Contains(methods, method) {
1118
+ methods = append(methods, method)
1119
+ }
1120
+ }
1121
+ if len(methods) == 0 {
1122
+ return nil, "", fmt.Errorf("payment methods are required before ':'")
1123
+ }
1124
+ return methods, amount, nil
1125
+}
1126
+
1127
func (m agentDashboardModel) startOrAddTunnel() (tea.Model, tea.Cmd) {
1128
if !m.addingTunnel {
1129
m.addingTunnel = true
1130
+ m.resetAddTunnelForm()
1131
m.setActivePane(agentDashboardPaneTunnels)
871
- _ = m.input.Focus()
1132
+ m.focusAddTunnelField(agentDashboardAddFieldName)
1133
return m, nil
1134
}
1135
return m.addTunnelFromInput()
@@ -876,8 +1137,7 @@ func (m agentDashboardModel) startOrAddTunnel() (tea.Model, tea.Cmd) {
1137
1138
func (m *agentDashboardModel) cancelTunnelInput() {
1139
m.addingTunnel = false
879
- m.input.Reset()
880
- m.input.Blur()
1140
+ m.resetAddTunnelForm()
1141
}
1142
1143
func (m agentDashboardModel) applySettingsEdit() (tea.Model, tea.Cmd) {
@@ -1154,7 +1414,7 @@ func (m agentDashboardModel) renderTunnelsSection(width, height int) agentDashbo
1414
addDisabled := false
1415
if m.addingTunnel {
1416
addLabel = "Create"
1157
- addDisabled = strings.TrimSpace(m.input.Value()) == ""
1417
+ addDisabled = strings.TrimSpace(m.addName.Value()) == ""
1418
}
1419
buttons := []agentDashboardButton{
1420
{label: addLabel, action: agentDashboardActionAddTunnel, disabled: addDisabled},
@@ -1167,7 +1427,7 @@ func (m agentDashboardModel) renderTunnelsSection(width, height int) agentDashbo
1427
)
1428
pane.addButtons(width, buttons...)
1429
if m.addingTunnel {
1170
- pane.addLine(m.input.View())
1430
+ m.renderAddTunnelForm(&pane, width)
1431
}
1432
tunnelRowWidth := agentDashboardTunnelTableWidth(width, m.status.Tunnels)
1433
pane.addLine(agentDashboardHeaderStyle.Render(agentDashboardTunnelRow(tunnelRowWidth, "STATUS", "TARGET", "TUNNEL")))
@@ -1199,6 +1459,25 @@ func (m agentDashboardModel) renderTunnelsSection(width, height int) agentDashbo
1459
return pane
1460
}
1461
1462
+func (m agentDashboardModel) renderAddTunnelForm(pane *agentDashboardView, width int) {
1463
+ rows := []struct {
1464
+ label string
1465
+ input textinput.Model
1466
+ field int
1467
+ }{
1468
+ {label: "Name", input: m.addName, field: agentDashboardAddFieldName},
1469
+ {label: "Target", input: m.addTarget, field: agentDashboardAddFieldTarget},
1470
+ {label: "Routes", input: m.addHTTPRoutes, field: agentDashboardAddFieldHTTPRoutes},
1471
+ {label: "X402 Pay To", input: m.addX402PayTo, field: agentDashboardAddFieldX402PayTo},
1472
+ {label: "Relays", input: m.addRelays, field: agentDashboardAddFieldRelays},
1473
+ {label: "Discovery", input: m.addDiscovery, field: agentDashboardAddFieldDiscovery},
1474
+ {label: "Max Relays", input: m.addMaxRelays, field: agentDashboardAddFieldMaxRelays},
1475
+ }
1476
+ for _, row := range rows {
1477
+ pane.addAddTunnelInputRow(width, row.label, row.input, row.field, m.addFocus == row.field)
1478
+ }
1479
+}
1480
+
1481
func (m agentDashboardModel) renderTunnelPane(width, height int) agentDashboardView {
1482
var pane agentDashboardView
1483
tunnel, ok := m.selectedTunnelStatus()
@@ -1307,6 +1586,61 @@ func (m agentDashboardModel) renderSettingsInputRows(pane *agentDashboardView, w
1586
}
1587
pane.addSettingsInputRow(width, row.label, row.input, row.field, m.settingsFocus == row.field)
1588
}
1589
+
1590
+ if len(pane.lines)-startLine >= height {
1591
+ return
1592
+ }
1593
+ pane.addMeta(width, 0, "Discovery", strconv.FormatBool(tunnel.Discovery))
1594
+
1595
+ paidRouteCount := 0
1596
+ for _, route := range tunnel.HTTPRoutes {
1597
+ if strings.TrimSpace(route.Amount) != "" {
1598
+ paidRouteCount++
1599
+ }
1600
+ }
1601
+ payTo := strings.TrimSpace(tunnel.X402PayTo)
1602
+ if payTo == "" && len(tunnel.HTTPRoutes) == 0 {
1603
+ return
1604
+ }
1605
+ if len(pane.lines)-startLine >= height {
1606
+ return
1607
+ }
1608
+ pane.addLine("")
1609
+ if len(pane.lines)-startLine >= height {
1610
+ return
1611
+ }
1612
+ pane.addStyled(width, agentDashboardLabelStyle, "Payments")
1613
+ if payTo != "" {
1614
+ if len(pane.lines)-startLine >= height {
1615
+ return
1616
+ }
1617
+ pane.addMeta(width, 0, "Pay To", payTo)
1618
+ }
1619
+ if len(tunnel.HTTPRoutes) == 0 {
1620
+ if len(pane.lines)-startLine >= height {
1621
+ return
1622
+ }
1623
+ pane.addStyled(width, agentDashboardMutedStyle, "no routed HTTP paths")
1624
+ return
1625
+ }
1626
+
1627
+ shown := 0
1628
+ for _, route := range tunnel.HTTPRoutes {
1629
+ if shown >= 4 {
1630
+ break
1631
+ }
1632
+ if len(pane.lines)-startLine >= height {
1633
+ return
1634
+ }
1635
+ pane.addText(width, agentDashboardHTTPRouteSummary(route))
1636
+ shown++
1637
+ }
1638
+ if len(tunnel.HTTPRoutes) > shown && len(pane.lines)-startLine < height {
1639
+ pane.addStyled(width, agentDashboardMutedStyle, fmt.Sprintf("+%d more routes", len(tunnel.HTTPRoutes)-shown))
1640
+ }
1641
+ if paidRouteCount == 0 && len(pane.lines)-startLine < height {
1642
+ pane.addStyled(width, agentDashboardMutedStyle, "no paid routes")
1643
+ }
1644
}
1645
1646
func (m agentDashboardModel) renderRouteSection(pane *agentDashboardView, width, height int, tunnel types.AgentTunnelStatus) {
@@ -1508,6 +1842,26 @@ func (v *agentDashboardView) addSettingsInputRow(width int, label string, input
1842
})
1843
}
1844
1845
+func (v *agentDashboardView) addAddTunnelInputRow(width int, label string, input textinput.Model, field int, focused bool) {
1846
+ if width <= 0 {
1847
+ width = 1
1848
+ }
1849
+ labelStyle := agentDashboardMutedStyle
1850
+ if focused {
1851
+ labelStyle = agentDashboardInputStyle
1852
+ }
1853
+ labelText := agentDashboardCell(label+":", 12)
1854
+ y := len(v.lines)
1855
+ v.lines = append(v.lines, labelStyle.Render(labelText)+" "+input.View())
1856
+ v.regions = append(v.regions, agentDashboardRegion{
1857
+ x0: 0,
1858
+ x1: width,
1859
+ y: y,
1860
+ action: agentDashboardActionFocusAddTunnelField,
1861
+ field: field,
1862
+ })
1863
+}
1864
+
1865
func (v *agentDashboardView) clip(height int) {
1866
if height <= 0 || len(v.lines) <= height {
1867
return
@@ -1644,6 +1998,33 @@ func relayDashboardConnected(tunnel types.AgentTunnelStatus, relay types.AgentRe
1998
return relay.PublicURL != "" || slices.Contains(tunnel.MultiHop, relay.RelayURL)
1999
}
2000
2001
+func agentDashboardHTTPRouteSummary(route types.AgentHTTPRoute) string {
2002
+ prefix := strings.TrimSpace(route.Prefix)
2003
+ if prefix == "" {
2004
+ prefix = "-"
2005
+ }
2006
+ upstream := strings.TrimSpace(route.Upstream)
2007
+ if upstream == "" {
2008
+ upstream = "-"
2009
+ }
2010
+ if amount := strings.TrimSpace(route.Amount); amount != "" {
2011
+ methods := "ALL"
2012
+ if len(route.Methods) > 0 {
2013
+ var normalized []string
2014
+ for _, raw := range route.Methods {
2015
+ if method := strings.ToUpper(strings.TrimSpace(raw)); method != "" {
2016
+ normalized = append(normalized, method)
2017
+ }
2018
+ }
2019
+ if len(normalized) > 0 {
2020
+ methods = strings.Join(normalized, ",")
2021
+ }
2022
+ }
2023
+ return fmt.Sprintf("%s -> %s (%s %s)", prefix, upstream, methods, amount)
2024
+ }
2025
+ return prefix + " -> " + upstream
2026
+}
2027
+
2028
func (m agentDashboardModel) settingsChanged(tunnel types.AgentTunnelStatus) bool {
2029
if m.settingsEditTunnelID != tunnel.ID {
2030
return false
@@ -1768,11 +2149,31 @@ func tunnelDashboardName(tunnel types.AgentTunnelStatus) string {
2149
return tunnel.ID
2150
}
2151
2152
+func tunnelDashboardTarget(tunnel types.AgentTunnelStatus) string {
2153
+ if target := strings.TrimSpace(tunnel.TargetAddr); target != "" {
2154
+ return target
2155
+ }
2156
+ if len(tunnel.HTTPRoutes) == 0 {
2157
+ return "-"
2158
+ }
2159
+ paid := 0
2160
+ for _, route := range tunnel.HTTPRoutes {
2161
+ if strings.TrimSpace(route.Amount) != "" {
2162
+ paid++
2163
+ }
2164
+ }
2165
+ if paid == 0 {
2166
+ return fmt.Sprintf("%d routes", len(tunnel.HTTPRoutes))
2167
+ }
2168
+ return fmt.Sprintf("%d routes, %d paid", len(tunnel.HTTPRoutes), paid)
2169
+}
2170
+
2171
func agentDashboardTunnelTableWidth(width int, tunnels []types.AgentTunnelStatus) int {
2172
tableWidth := 56
2173
for _, tunnel := range tunnels {
2174
nameWidth := max(lipgloss.Width(tunnelDashboardName(tunnel)), lipgloss.Width("TUNNEL"))
1775
- tableWidth = max(tableWidth, 11+1+22+1+nameWidth)
2175
+ targetWidth := max(lipgloss.Width(tunnelDashboardTarget(tunnel)), lipgloss.Width("TARGET"))
2176
+ tableWidth = max(tableWidth, 11+1+targetWidth+1+nameWidth)
2177
}
2178
return max(1, min(tableWidth, width))
2179
}
cmd/portal-tunnel/agent/manager.go
+55
-6
@@ -220,7 +220,19 @@ func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
220
return err
221
}
222
target := strings.TrimSpace(req.TargetAddr)
223
- if target == "" {
223
+ httpRoutes := make([]HTTPRouteConfig, 0, len(req.HTTPRoutes))
224
+ for _, route := range req.HTTPRoutes {
225
+ httpRoutes = append(httpRoutes, HTTPRouteConfig{
226
+ Prefix: strings.TrimSpace(route.Prefix),
227
+ Upstream: strings.TrimSpace(route.Upstream),
228
+ Methods: normalizeAgentHTTPRouteMethods(route.Methods),
229
+ Amount: strings.TrimSpace(route.Amount),
230
+ })
231
+ }
232
+ if target != "" && len(httpRoutes) > 0 {
233
+ return errors.New("target cannot be combined with http_routes")
234
+ }
235
+ if target == "" && len(httpRoutes) == 0 {
236
target = defaultTargetAddr
237
}
238
if name == "" {
@@ -231,12 +243,21 @@ func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
243
return err
244
}
245
discovery := true
246
+ if req.Discovery != nil {
247
+ discovery = *req.Discovery
248
+ }
249
+ if req.MaxActiveRelays < 0 {
250
+ return errors.New("max_active_relays cannot be negative")
251
+ }
252
tunnelCfg := TunnelConfig{
235
- ID: id,
236
- Name: name,
237
- TargetAddr: target,
238
- RelayURLs: relayURLs,
239
- Discovery: &discovery,
253
+ ID: id,
254
+ Name: name,
255
+ TargetAddr: target,
256
+ HTTPRoutes: httpRoutes,
257
+ RelayURLs: relayURLs,
258
+ Discovery: &discovery,
259
+ MaxActiveRelays: req.MaxActiveRelays,
260
+ X402PayTo: strings.TrimSpace(req.X402PayTo),
261
}
262
if slices.ContainsFunc(cfg.Tunnels, func(tunnel TunnelConfig) bool { return tunnel.ID == tunnelCfg.ID }) {
263
return fmt.Errorf("tunnel %q already exists", tunnelCfg.ID)
@@ -263,6 +284,17 @@ func agentTunnelID(name string) string {
284
return strings.Trim(out.String(), "-")
285
}
286
287
+func normalizeAgentHTTPRouteMethods(methods []string) []string {
288
+ out := make([]string, 0, len(methods))
289
+ for _, raw := range methods {
290
+ method := strings.ToUpper(strings.TrimSpace(raw))
291
+ if method != "" && !slices.Contains(out, method) {
292
+ out = append(out, method)
293
+ }
294
+ }
295
+ return out
296
+}
297
+
298
func (m *manager) updateTunnelConfig(id string, update func(*TunnelConfig) error) error {
299
id = strings.TrimSpace(id)
300
if err := validateAgentPathComponent("tunnel id", id); err != nil {
@@ -565,6 +597,10 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
597
case running:
598
state = "starting"
599
}
600
+ discovery := true
601
+ if cfg.Discovery != nil {
602
+ discovery = *cfg.Discovery
603
+ }
604
605
status := types.AgentTunnelStatus{
606
ID: cfg.ID,
@@ -572,9 +608,22 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
608
State: state,
609
TargetAddr: cfg.TargetAddr,
610
LastError: lastError,
611
+ Discovery: discovery,
612
MaxActiveRelays: cfg.MaxActiveRelays,
613
Metadata: metadataFromTunnelConfig(cfg),
614
MultiHop: append([]string(nil), cfg.MultiHop...),
615
+ X402PayTo: strings.TrimSpace(cfg.X402PayTo),
616
+ }
617
+ if len(cfg.HTTPRoutes) > 0 {
618
+ status.HTTPRoutes = make([]types.AgentHTTPRoute, 0, len(cfg.HTTPRoutes))
619
+ for _, route := range cfg.HTTPRoutes {
620
+ status.HTTPRoutes = append(status.HTTPRoutes, types.AgentHTTPRoute{
621
+ Prefix: route.Prefix,
622
+ Upstream: route.Upstream,
623
+ Methods: append([]string(nil), route.Methods...),
624
+ Amount: route.Amount,
625
+ })
626
+ }
627
}
628
if exposure == nil {
629
if strings.TrimSpace(runtime.Address) != "" {
cmd/portal-tunnel/main.go
+60
-63
@@ -60,7 +60,6 @@ type exposeFlags struct {
60
thumbnail string
61
hide bool
62
x402PayTo string
63
- x402Amounts []string
63
targetAddr string
64
httpRoutes []string
65
udp bool
@@ -90,8 +89,7 @@ func runExposeCommand(args []string) error {
89
utils.StringFlag(fs, &flags.thumbnail, "thumbnail", "", "Service thumbnail URL metadata")
90
utils.BoolFlag(fs, &flags.hide, "hide", false, "Hide service from relay listing screens")
91
utils.StringFlag(fs, &flags.x402PayTo, "x402-pay-to", "", "Sui USDC payment recipient address for this tunnel")
93
- utils.RepeatedStringFlag(fs, &flags.x402Amounts, "x402-amount", "Sui USDC x402 amount mapping in [METHOD[,METHOD...]:]PATH=ATOMIC_AMOUNT form; repeat for multiple HTTP routes")
94
- utils.RepeatedStringFlag(fs, &flags.httpRoutes, "http-route", "HTTP route mapping in PATH=UPSTREAM form; repeat to aggregate multiple local HTTP services behind one public URL")
92
+ 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")
93
utils.BoolFlagEnv(fs, &flags.udp, "udp", false, "Enable public UDP relay in addition to the default TCP relay", "UDP_ENABLED")
94
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")
95
utils.BoolFlagEnv(fs, &flags.tcp, "tcp", false, "Request a dedicated TCP port on the relay for raw TCP services (no TLS; e.g., Minecraft, game servers)", "TCP_ENABLED")
@@ -123,61 +121,17 @@ func runExposeCommand(args []string) error {
121
case len(httpRouteInputs) > 0 && flags.udp:
122
printExposeUsage(os.Stderr)
123
return errors.New("--udp cannot be combined with --http-route")
126
- case len(flags.x402Amounts) > 0 && len(httpRouteInputs) == 0:
127
- printExposeUsage(os.Stderr)
128
- return errors.New("--x402-amount requires --http-route")
129
- case len(flags.x402Amounts) > 0 && strings.TrimSpace(flags.x402PayTo) == "":
130
- printExposeUsage(os.Stderr)
131
- return errors.New("--x402-amount requires --x402-pay-to")
132
- }
133
-
134
- type x402AmountRule struct {
135
- methods []string
136
- amount string
137
- }
138
- x402Amounts := make(map[string]x402AmountRule, len(flags.x402Amounts))
139
- for _, raw := range flags.x402Amounts {
140
- prefix, amount, ok := strings.Cut(raw, "=")
141
- if !ok {
142
- return fmt.Errorf("--x402-amount %q: expected [METHOD[,METHOD...]:]PATH=ATOMIC_AMOUNT", raw)
143
- }
144
- prefix = strings.TrimSpace(prefix)
145
- methods := []string(nil)
146
- if !strings.HasPrefix(prefix, "/") {
147
- methodPart, pathPart, ok := strings.Cut(prefix, ":")
148
- if ok {
149
- for _, rawMethod := range strings.Split(methodPart, ",") {
150
- method := strings.ToUpper(strings.TrimSpace(rawMethod))
151
- if method == "" {
152
- return fmt.Errorf("--x402-amount %q: method is required", raw)
153
- }
154
- methods = append(methods, method)
155
- }
156
- prefix = strings.TrimSpace(pathPart)
157
- }
158
- }
159
- if prefix == "" {
160
- return fmt.Errorf("--x402-amount %q: path is required", raw)
161
- }
162
- if !strings.HasPrefix(prefix, "/") {
163
- return fmt.Errorf("--x402-amount %q: path must start with /", raw)
164
- }
165
- prefix = utils.NormalizeURLPath(prefix)
166
- amount = strings.TrimSpace(amount)
167
- if amount == "" {
168
- return fmt.Errorf("--x402-amount %q: amount is required", raw)
169
- }
170
- if _, exists := x402Amounts[prefix]; exists {
171
- return fmt.Errorf("--x402-amount path %q repeated", prefix)
172
- }
173
- x402Amounts[prefix] = x402AmountRule{methods: methods, amount: amount}
124
}
125
126
httpRoutes := make([]sdk.HTTPRouteConfig, 0, len(httpRouteInputs))
127
for _, raw := range httpRouteInputs {
178
- prefix, upstream, ok := strings.Cut(raw, "=")
128
+ fields := strings.Fields(raw)
129
+ if len(fields) == 0 || len(fields) > 2 {
130
+ return fmt.Errorf("--http-route %q: expected PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]", raw)
131
+ }
132
+ prefix, upstream, ok := strings.Cut(fields[0], "=")
133
if !ok {
180
- return fmt.Errorf("--http-route %q: expected PATH=UPSTREAM", raw)
134
+ return fmt.Errorf("--http-route %q: expected PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]", raw)
135
}
136
prefix = strings.TrimSpace(prefix)
137
if prefix == "" {
@@ -190,21 +144,23 @@ func runExposeCommand(args []string) error {
144
if upstream == "" {
145
return fmt.Errorf("--http-route %q: upstream is required", raw)
146
}
193
- normalizedPrefix := utils.NormalizeURLPath(prefix)
147
route := sdk.HTTPRouteConfig{
148
Prefix: prefix,
149
Upstream: upstream,
150
}
198
- if payment, ok := x402Amounts[normalizedPrefix]; ok {
199
- route.Methods = payment.methods
200
- route.Amount = payment.amount
201
- delete(x402Amounts, normalizedPrefix)
151
+ if len(fields) == 2 {
152
+ methods, amount, err := parseHTTPRoutePayment(fields[1])
153
+ if err != nil {
154
+ return fmt.Errorf("--http-route %q: %w", raw, err)
155
+ }
156
+ if strings.TrimSpace(flags.x402PayTo) == "" {
157
+ return fmt.Errorf("--http-route %q: payment amount requires --x402-pay-to", raw)
158
+ }
159
+ route.Methods = methods
160
+ route.Amount = amount
161
}
162
httpRoutes = append(httpRoutes, route)
163
}
205
- for prefix := range x402Amounts {
206
- return fmt.Errorf("--x402-amount path %q has no matching --http-route", prefix)
207
- }
164
165
ctx, stop := utils.SignalContext()
166
defer stop()
@@ -258,6 +214,45 @@ func runExposeCommand(args []string) error {
214
return sdk.ProxyExposure(ctx, exposure)
215
}
216
217
+func parseHTTPRoutePayment(value string) ([]string, string, error) {
218
+ value = strings.TrimSpace(value)
219
+ if value == "" {
220
+ return nil, "", errors.New("payment amount is required")
221
+ }
222
+ methodPart, amount, hasMethods := strings.Cut(value, ":")
223
+ if !hasMethods {
224
+ amount = value
225
+ methodPart = ""
226
+ }
227
+ amount = strings.TrimSpace(amount)
228
+ if amount == "" {
229
+ return nil, "", errors.New("payment amount is required")
230
+ }
231
+ methods := []string(nil)
232
+ if hasMethods {
233
+ for _, rawMethod := range strings.Split(methodPart, ",") {
234
+ method := strings.ToUpper(strings.TrimSpace(rawMethod))
235
+ if method == "" {
236
+ return nil, "", errors.New("payment method is required")
237
+ }
238
+ exists := false
239
+ for _, existing := range methods {
240
+ if existing == method {
241
+ exists = true
242
+ break
243
+ }
244
+ }
245
+ if !exists {
246
+ methods = append(methods, method)
247
+ }
248
+ }
249
+ if len(methods) == 0 {
250
+ return nil, "", errors.New("payment methods are required before ':'")
251
+ }
252
+ }
253
+ return methods, amount, nil
254
+}
255
+
256
func runUpdateCommand(args []string) error {
257
var version string
258
fs := utils.NewFlagSet("update", printUpdateUsage)
@@ -347,7 +342,7 @@ func printRootUsage(w io.Writer) {
342
utils.WriteCommandUsage(w,
343
[]string{
344
"portal expose [flags] <target>",
350
- "portal expose [flags] --http-route PATH=UPSTREAM [--http-route PATH=UPSTREAM]",
345
+ "portal expose [flags] --http-route \"PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]\" [...]",
346
"portal agent run [flags]",
347
"portal agent dashboard [flags]",
348
"portal agent stop [flags]",
@@ -360,6 +355,7 @@ func printRootUsage(w io.Writer) {
355
"portal expose 3000",
356
"portal expose localhost:8080 --name my-app",
357
"portal expose --http-route /api=http://127.0.0.1:3001 --http-route /=http://127.0.0.1:5173 --name my-app",
358
+ "portal expose --http-route \"/paid=http://127.0.0.1:3001 GET:0.01\" --http-route /=http://127.0.0.1:5173 --x402-pay-to 0x...",
359
"portal agent run",
360
"portal agent dashboard",
361
"portal agent stop",
@@ -377,12 +373,13 @@ func printExposeUsage(w io.Writer) {
373
utils.WriteCommandUsage(w,
374
[]string{
375
"portal expose [flags] <target>",
380
- "portal expose [flags] --http-route PATH=UPSTREAM [--http-route PATH=UPSTREAM]",
376
+ "portal expose [flags] --http-route \"PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]\" [...]",
377
},
378
[]string{
379
"portal expose 3000",
380
"portal expose localhost:8080 --name my-app",
381
"portal expose --http-route /api=http://127.0.0.1:3001 --http-route /=http://127.0.0.1:5173 --name my-app",
382
+ "portal expose --http-route \"/paid=http://127.0.0.1:3001 GET:0.01\" --http-route /=http://127.0.0.1:5173 --x402-pay-to 0x...",
383
"portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
384
"portal expose 3000 --ban-mitm",
385
"portal expose 3000 --relays https://portal.example.com --discovery=false",
docker-compose.yml
+3
-3
@@ -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"
@@ -93,7 +93,7 @@ services:
93
# Public routing, discovery, and relay identity persistence
94
PORTAL_URL: ${PORTAL_URL:-https://localhost}
95
BOOTSTRAPS: ${BOOTSTRAPS:-}
96
- DISCOVERY: ${DISCOVERY:-true}
96
+ DISCOVERY: ${DISCOVERY:-false}
97
IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs}
98
99
API_PORT: 4017
docs/src/routes/api-reference/+page.md
+5
@@ -122,6 +122,11 @@ 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.
124
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.
129
+
130
| Method | Path | Auth | Body | Response |
131
|--------|------|------|------|----------|
132
| `GET` | `/api/x402/supported` | None | none | x402 supported kinds |
docs/src/routes/cli-reference/+page.md
+32
-9
@@ -56,9 +56,11 @@ portal expose [flags] <target>
56
Or run routed HTTP mode:
57
58
```bash
59
-portal expose [flags] --http-route PATH=UPSTREAM [--http-route PATH=UPSTREAM]
59
+portal expose [flags] --http-route "PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]" [...]
60
```
61
62
+The payment suffix is optional; omit it for free routes.
63
+
64
### Target Formats
65
66
| Format | Example | Resolves to |
@@ -98,8 +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 |
101
-| `--x402-amount` | string | | Sui USDC x402 amount mapping in `[METHOD[,METHOD...]:]PATH=ATOMIC_AMOUNT` form; repeatable; requires `--http-route` and `--x402-pay-to` |
102
-| `--http-route` | string | | HTTP route mapping in `PATH=UPSTREAM` form; repeatable |
103
+| `--http-route` | string | | HTTP route mapping in `PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]` form; repeatable; route amounts require `--x402-pay-to` |
104
| `--tcp` | bool | `false` | Request a dedicated raw TCP port on the relay |
105
| `--udp` | bool | `false` | Enable public UDP relay in addition to the default stream path |
106
| `--udp-addr` | string | | Local UDP target; defaults to the primary target when `--udp` is enabled |
@@ -112,8 +113,8 @@ not supported.
113
- Explicit `--multi-hop` cannot be combined with automatic `--multi-hop-depth`.
114
- Multi-hop currently supports only the default SNI TLS stream transport.
115
- `--tcp` and `--udp` require matching transport support on the relay.
115
-- `--x402-amount` applies only to routed HTTP prefixes and requires a
116
- tunnel-owned `--x402-pay-to`.
116
+- Route payment amounts are part of `--http-route` and require a tunnel-owned
117
+ `--x402-pay-to`.
118
119
### Examples
120
@@ -178,13 +179,35 @@ portal expose 3000 --ban-mitm
179
Publish a paid HTTP route:
180
181
```bash
181
-portal expose --name myapp \
182
- --http-route /api=http://127.0.0.1:3001 \
182
+portal expose --name paid-app \
183
+ --http-route "/paid=http://127.0.0.1:3001 GET:0.01" \
184
--http-route /=http://127.0.0.1:5173 \
184
- --x402-pay-to 0x... \
185
- --x402-amount /api=100000
185
+ --x402-pay-to 0x...
186
+```
187
+
188
+The optional method list limits which methods require payment; without it, every
189
+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:
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');
200
+
201
+const response = await x402Fetch('/paid/photo', { method: 'GET' }, {
202
+ wallet,
203
+ account,
204
+ network: 'sui:testnet',
205
+});
206
```
207
208
+The frontend integration is optional. Requests without a valid `X-PAYMENT`
209
+header still receive x402 payment-required responses from the tunnel.
210
+
211
## `portal list`
212
213
Print relay URLs resolved for the current invocation:
docs/src/routes/concepts/+page.md
+16
@@ -85,6 +85,22 @@ Because HTTP is parsed in the tunnel process, this is the right place for
85
cooperative HTTP policy such as response headers. It is not a relay-enforced
86
policy boundary.
87
88
+Paid routes are also owned by routed HTTP mode. Add `--x402-pay-to` and attach
89
+the amount to the HTTP route:
90
+
91
+```bash
92
+portal expose --name paid-app \
93
+ --http-route "/paid=http://127.0.0.1:3001 GET:0.01" \
94
+ --http-route /=http://127.0.0.1:5173 \
95
+ --x402-pay-to 0x...
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.
103
+
104
## Dedicated Raw TCP
105
106
Use raw TCP when clients need a public TCP port instead of a public HTTPS
docs/src/routes/configuration/+page.md
+10
-4
@@ -163,13 +163,12 @@ 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-amount` | | string | | Sui USDC x402 amount mapping in `[METHOD[,METHOD...]:]PATH=ATOMIC_AMOUNT` form; repeatable; requires `--http-route` and `--x402-pay-to` |
166
167
### Routing
168
169
| Flag | Env Var | Type | Default | Description |
170
|------|---------|------|---------|-------------|
172
-| `--http-route` | | string | | HTTP route mapping in `PATH=UPSTREAM` form; repeat to aggregate multiple local HTTP services behind one public URL |
171
+| `--http-route` | | string | | HTTP route mapping in `PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]` form; repeat to aggregate multiple local HTTP services behind one public URL; route amounts require `--x402-pay-to` |
172
173
### Transport
174
@@ -227,7 +226,7 @@ x402_pay_to = "0x..."
226
prefix = "/api"
227
upstream = "http://127.0.0.1:3001"
228
methods = ["GET"]
230
-amount = "100000"
229
+amount = "0.01"
230
231
[[tunnels.http_routes]]
232
prefix = "/"
@@ -263,8 +262,15 @@ Tunnel fields mirror `portal expose` flags:
262
| `udp`, `udp_addr`, `tcp` | bool/string | UDP and raw TCP relay options |
263
| `description`, `tags`, `owner`, `thumbnail`, `hide` | mixed | Lease metadata shown by relays |
264
| `x402_pay_to` | string | Tunnel-owned Sui USDC x402 payment recipient for paid HTTP routes |
266
-| `http_routes[].amount` | string | Optional Sui USDC x402 atomic amount for one HTTP route prefix; requires `x402_pay_to` |
265
+| `http_routes[].amount` | string | Optional Sui USDC x402 amount, such as `0.01`, for one HTTP route prefix; requires `x402_pay_to` |
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.
273
+
274
For a task-oriented walkthrough, see [Portal Agent](/portal-agent).
275
276
### `identity.json`
docs/src/routes/portal-agent/+page.md
+26
-7
@@ -76,13 +76,18 @@ x402_pay_to = "0x..."
76
prefix = "/api"
77
upstream = "http://127.0.0.1:3001"
78
methods = ["GET"]
79
-amount = "100000"
79
+amount = "0.01"
80
81
[[tunnels.http_routes]]
82
prefix = "/"
83
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.
90
+
91
Relative paths in the config are resolved from the config file directory.
92
93
## Run The Agent
@@ -128,7 +133,7 @@ Dashboard panes:
133
134
| Pane | Purpose |
135
|------|---------|
131
-| Tunnels | Add, select, and delete simple target tunnels |
136
+| Tunnels | Add, select, and delete tunnels |
137
| Settings | Edit max active relays and public metadata |
138
| Relays | Connect or disconnect relays for the selected tunnel |
139
| Multi-hop | Build and apply an ordered multi-hop route |
@@ -149,9 +154,23 @@ Keyboard controls:
154
| `esc` | Cancel input or return to the Tunnels pane |
155
| `ctrl+c` | Exit the dashboard |
156
152
-The Add Tunnel action accepts `name port`, for example `myapp 3000`. It creates
153
-a simple loopback target tunnel. Advanced options such as `http_routes`, UDP,
154
-TCP, custom identity JSON, or explicit multi-hop defaults should be edited in
157
+The Add Tunnel action opens a form. Fill either `Target` for a simple loopback
158
+tunnel or `Routes` for routed HTTP. Routes use this syntax:
159
+
160
+```text
161
+/paid=3001 GET:0.01; /=5173
162
+```
163
+
164
+Each entry is `PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]`. Fill `X402 Pay To`
165
+when any route has an amount. The form also accepts explicit `Relays`,
166
+`Discovery`, and `Max Relays`; max relays caps auto-selected discovery relays
167
+while explicit relays are still included.
168
+
169
+After creation, routed HTTP paths, x402 payment amounts, and discovery mode are
170
+read-only in the Settings pane. To change routes, payment amounts, or discovery
171
+mode, edit `http_routes`, `x402_pay_to`, and `discovery` in `config.toml`, then
172
+restart the agent or tunnel. Other advanced options such as UDP, TCP, custom
173
+identity JSON, or explicit multi-hop defaults are also configured in
174
`config.toml`.
175
176
## Tunnel Config Fields
@@ -176,7 +195,7 @@ Common fields:
195
| `ban_mitm` | Ban relays when the TLS self-probe detects termination; defaults to warning-only |
196
| `description`, `tags`, `owner`, `thumbnail`, `hide` | Public relay metadata |
197
| `x402_pay_to` | Tunnel-owned Sui USDC x402 recipient for paid HTTP routes |
179
-| `http_routes[].amount` | Optional Sui USDC x402 atomic amount for one HTTP route prefix |
198
+| `http_routes[].amount` | Optional Sui USDC x402 amount, such as `0.01`, for one HTTP route prefix |
199
| `http_routes[].methods` | Optional HTTP methods that require payment on that route; empty means every method |
200
201
Constraints match `portal expose`:
@@ -222,7 +241,7 @@ Control endpoints:
241
|--------|------|------|---------|
242
| `GET` | `/agent/status` | Bearer token or wallet session | Read agent and tunnel status |
243
| `POST` | `/agent/shutdown` | Bearer token | Ask the agent to stop |
225
-| `POST` | `/agent/tunnels` | Bearer token | Add a simple target tunnel |
244
+| `POST` | `/agent/tunnels` | Bearer token | Add a tunnel |
245
| `PATCH` | `/agent/tunnels/{id}` | Bearer token | Update metadata or max active relays |
246
| `DELETE` | `/agent/tunnels/{id}` | Bearer token | Delete a tunnel |
247
| `POST` | `/agent/tunnels/{id}/relays` | Bearer token | Connect a relay |
go.mod
+1
@@ -11,6 +11,7 @@ require (
11
github.com/charmbracelet/bubbles v1.0.0
12
github.com/charmbracelet/bubbletea v1.3.10
13
github.com/charmbracelet/lipgloss v1.1.0
14
+ github.com/cockroachdb/apd/v3 v3.2.3
15
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
16
github.com/go-acme/lego/v4 v4.34.0
17
github.com/go-jose/go-jose/v4 v4.1.4
go.sum
+2
@@ -97,6 +97,8 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
97
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
98
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
99
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
100
+github.com/cockroachdb/apd/v3 v3.2.3 h1:4Zx+I3R35bFXMnltzmjP79i2cravE4jTRL6ps9Aux80=
101
+github.com/cockroachdb/apd/v3 v3.2.3/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=
102
github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
103
github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
104
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4=
portal/x402/client.js
new
+209
@@ -0,0 +1,209 @@
1
+import { getWallets } from 'https://esm.sh/@wallet-standard/app';
2
+import { Transaction } from 'https://esm.sh/@mysten/sui/transactions';
3
+
4
+const walletAPI = getWallets();
5
+
6
+export function getSuiWallets({ network = '' } = {}) {
7
+ network = String(network || '').trim();
8
+ return walletAPI.get().map((wallet, index) => {
9
+ const features = wallet.features || {};
10
+ const connect = features['standard:connect']?.connect?.bind(features['standard:connect']);
11
+ const sign = features['sui:signTransaction']?.signTransaction?.bind(features['sui:signTransaction']);
12
+ const signBlock = features['sui:signTransactionBlock']?.signTransactionBlock?.bind(features['sui:signTransactionBlock']);
13
+ if (!connect || (!sign && !signBlock)) {
14
+ return null;
15
+ }
16
+
17
+ const out = {
18
+ id: wallet.name || `wallet-${index}`,
19
+ name: wallet.name || `Wallet ${index + 1}`,
20
+ async accounts(chain = network) {
21
+ const connected = await connect();
22
+ return normalizeAccounts(connected?.accounts || wallet.accounts || [])
23
+ .filter((account) => !chain || !Array.isArray(account.chains) || account.chains.includes(chain));
24
+ },
25
+ async connect(chain = network, address = '') {
26
+ const accounts = await out.accounts(chain);
27
+ const normalizedAddress = String(address || '').trim().toLowerCase();
28
+ if (normalizedAddress) {
29
+ const account = accounts.find((candidate) => String(candidate.address || '').trim().toLowerCase() === normalizedAddress);
30
+ if (!account) {
31
+ throw new Error('Selected account is not available in the connected wallet');
32
+ }
33
+ return account;
34
+ }
35
+ if (accounts.length !== 1) {
36
+ if (accounts.length > 1) {
37
+ throw new Error('Select a Sui account before paying');
38
+ }
39
+ throw new Error('Connected wallet did not return an account');
40
+ }
41
+ return accounts[0];
42
+ },
43
+ async signTransaction(account, transaction, chain = network) {
44
+ if (sign) {
45
+ return sign({ account, chain, transaction });
46
+ }
47
+ return signBlock({ account, chain, transactionBlock: transaction });
48
+ },
49
+ };
50
+
51
+ const execute = features['sui:signAndExecuteTransaction']?.signAndExecuteTransaction?.bind(features['sui:signAndExecuteTransaction']);
52
+ const executeBlock = features['sui:signAndExecuteTransactionBlock']?.signAndExecuteTransactionBlock?.bind(features['sui:signAndExecuteTransactionBlock']);
53
+ if (execute || executeBlock) {
54
+ out.executeTransaction = async (account, transaction, chain = network) => {
55
+ if (execute) {
56
+ return execute({ account, chain, transaction, options: { showEffects: true } });
57
+ }
58
+ return executeBlock({ account, chain, transactionBlock: transaction, options: { showEffects: true } });
59
+ };
60
+ }
61
+ return out;
62
+ }).filter(Boolean);
63
+}
64
+
65
+export function onSuiWalletChange(callback) {
66
+ if (typeof walletAPI.on !== 'function') {
67
+ return () => {};
68
+ }
69
+ const offRegister = walletAPI.on('register', callback);
70
+ const offUnregister = walletAPI.on('unregister', callback);
71
+ return () => {
72
+ offRegister?.();
73
+ offUnregister?.();
74
+ };
75
+}
76
+
77
+export async function prepareX402Payment(options = {}) {
78
+ const fetcher = options.fetch || fetch.bind(globalThis);
79
+ const method = String(options.method || 'GET').trim().toUpperCase();
80
+ const path = String(options.path || '').trim();
81
+ if (!path || !path.startsWith('/')) {
82
+ throw new Error(path ? 'path must start with /' : 'path is required');
83
+ }
84
+
85
+ const wallet = options.wallet || getSuiWallets({ network: options.network })[0];
86
+ if (!wallet) {
87
+ throw new Error('No Sui wallet selected');
88
+ }
89
+
90
+ options.onStatus?.('Connecting wallet');
91
+ const network = String(options.network || '').trim();
92
+ const account = options.account || await wallet.connect(network, options.address);
93
+ if (!account?.address) {
94
+ throw new Error('Connected wallet did not return an account');
95
+ }
96
+ if (network && Array.isArray(account.chains) && !account.chains.includes(network)) {
97
+ throw new Error(`Connected account does not advertise ${network}`);
98
+ }
99
+
100
+ options.onStatus?.('Preparing USDC transaction');
101
+ const prepareURL = options.preparePath || '/x402/prepare';
102
+ const prepareBody = { sender: account.address, method, path };
103
+ let prepared = await requestPrepare(fetcher, prepareURL, prepareBody);
104
+ let paymentNetwork = String(prepared.paymentRequirements?.network || network).trim();
105
+ if (paymentNetwork && Array.isArray(account.chains) && !account.chains.includes(paymentNetwork)) {
106
+ throw new Error(`Connected account does not advertise ${paymentNetwork}`);
107
+ }
108
+
109
+ if (prepared.prepareTransaction?.transaction) {
110
+ if (!wallet.executeTransaction) {
111
+ throw new Error('Selected wallet cannot execute the USDC prepare transaction');
112
+ }
113
+ options.onStatus?.('Preparing object balance in wallet');
114
+ const prepareResult = await wallet.executeTransaction(account, Transaction.from(fromBase64(prepared.prepareTransaction.transaction)), paymentNetwork);
115
+ const prepareStatus = prepareResult?.effects?.status?.status || prepareResult?.effects?.status;
116
+ if (prepareStatus && prepareStatus !== 'success') {
117
+ throw new Error(prepareResult.effects?.status?.error || 'USDC prepare transaction failed');
118
+ }
119
+
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
+ paymentNetwork = String(prepared.paymentRequirements?.network || paymentNetwork).trim();
125
+ }
126
+ if (prepared.prepareTransaction?.transaction) {
127
+ throw new Error('Prepared USDC balance is not indexed yet');
128
+ }
129
+ }
130
+
131
+ if (!prepared.paymentTransaction?.transaction) {
132
+ throw new Error('Payment prepare response is missing a payment transaction');
133
+ }
134
+ options.onStatus?.('Signing x402 payment');
135
+ const signed = await wallet.signTransaction(account, Transaction.from(fromBase64(prepared.paymentTransaction.transaction)), paymentNetwork);
136
+ const signature = typeof signed?.signature === 'string' ? signed.signature : (Array.isArray(signed?.signatures) ? signed.signatures[0] : '');
137
+ const transaction = signed?.bytes || signed?.transactionBlockBytes || prepared.paymentTransaction.transaction;
138
+ const transactionBytes = transaction instanceof Uint8Array ? toBase64(transaction) : transaction;
139
+ if (!signature || !transactionBytes) {
140
+ throw new Error('Wallet did not return a signed payment transaction');
141
+ }
142
+ if (transactionBytes !== prepared.paymentTransaction.transaction) {
143
+ throw new Error('Wallet returned different payment transaction bytes');
144
+ }
145
+
146
+ const payload = {
147
+ x402Version: prepared.x402Version,
148
+ payload: {
149
+ signature,
150
+ transaction: transactionBytes,
151
+ },
152
+ accepted: prepared.paymentRequirements,
153
+ resource: prepared.resource,
154
+ };
155
+ return {
156
+ account,
157
+ prepared,
158
+ payload,
159
+ paymentHeader: toBase64(new TextEncoder().encode(JSON.stringify(payload))),
160
+ };
161
+}
162
+
163
+export async function x402Fetch(input, init = {}, options = {}) {
164
+ const request = input instanceof Request ? new Request(input, init) : new Request(new URL(String(input), globalThis.location.href).href, init);
165
+ const paid = await prepareX402Payment({
166
+ ...options,
167
+ method: request.method,
168
+ path: options.path || new URL(request.url).pathname,
169
+ });
170
+
171
+ options.onStatus?.('Settling payment');
172
+ const headers = new Headers(request.headers);
173
+ headers.set('X-PAYMENT', paid.paymentHeader);
174
+ return (options.fetch || fetch.bind(globalThis))(new Request(request, { headers }));
175
+}
176
+
177
+async function requestPrepare(fetcher, prepareURL, prepareBody) {
178
+ const response = await fetcher(prepareURL, {
179
+ method: 'POST',
180
+ headers: { 'Content-Type': 'application/json' },
181
+ body: JSON.stringify(prepareBody),
182
+ });
183
+ if (!response.ok) {
184
+ throw new Error(await response.text());
185
+ }
186
+ return response.json();
187
+}
188
+
189
+function normalizeAccounts(value) {
190
+ const accounts = Array.isArray(value) ? value : (value ? [value] : []);
191
+ return accounts.map((account) => {
192
+ if (typeof account === 'string') {
193
+ return { address: account };
194
+ }
195
+ return account && typeof account.address === 'string' ? account : null;
196
+ }).filter(Boolean);
197
+}
198
+
199
+function fromBase64(value) {
200
+ return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
201
+}
202
+
203
+function toBase64(bytes) {
204
+ let binary = '';
205
+ for (let i = 0; i < bytes.length; i += 0x8000) {
206
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
207
+ }
208
+ return btoa(binary);
209
+}
portal/x402/handler.go
+17
@@ -2,6 +2,7 @@ package x402
2
3
import (
4
"context"
5
+ _ "embed"
6
"encoding/json"
7
"errors"
8
"fmt"
@@ -12,6 +13,22 @@ import (
13
"github.com/gosuda/portal-tunnel/v2/utils"
14
)
15
16
+//go:embed client.js
17
+var clientJS []byte
18
+
19
+// ServeClientJS serves the shared browser x402 wallet/payment client.
20
+func ServeClientJS(w http.ResponseWriter, r *http.Request) {
21
+ if r.Method != http.MethodHead && !utils.RequireMethod(w, r, http.MethodGet) {
22
+ return
23
+ }
24
+ w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
25
+ w.Header().Set("Cache-Control", "no-store")
26
+ if r.Method == http.MethodHead {
27
+ return
28
+ }
29
+ _, _ = w.Write(clientJS)
30
+}
31
+
32
// USDCPaymentHandler serves both the wallet prepare endpoint and one protected resource.
33
type USDCPaymentHandler struct {
34
payment *Payment
portal/x402/payment.go
+46
-5
@@ -7,9 +7,9 @@ import (
7
"errors"
8
"fmt"
9
"net/http"
10
- "strconv"
10
"strings"
11
12
+ "github.com/cockroachdb/apd/v3"
13
facilitatorcore "github.com/gosuda/x402-facilitator/facilitator"
14
suischeme "github.com/gosuda/x402-facilitator/scheme/sui"
15
facilitatortypes "github.com/gosuda/x402-facilitator/types"
@@ -18,6 +18,8 @@ import (
18
"github.com/gosuda/portal-tunnel/v2/utils"
19
)
20
21
+const usdcDecimals = 6
22
+
23
// Payment owns one Sui USDC x402 payment contract and its facilitator runtime.
24
type Payment struct {
25
payment types.X402Payment
@@ -39,10 +41,9 @@ func NewUSDCPayment(payment types.X402Payment) (*Payment, error) {
41
if payTo == "" {
42
return nil, errors.New("x402 USDC payment requires a Sui pay-to address")
43
}
42
- amount := strings.TrimSpace(payment.Amount)
43
- n, err := strconv.ParseUint(amount, 10, 64)
44
- if err != nil || n == 0 {
45
- return nil, fmt.Errorf("x402 USDC payment amount must be a positive atomic amount: %s", amount)
44
+ amount, err := USDCAmountToAtomic(payment.Amount)
45
+ if err != nil {
46
+ return nil, err
47
}
48
maxTimeoutSeconds := payment.MaxTimeoutSeconds
49
if maxTimeoutSeconds <= 0 {
@@ -88,6 +89,46 @@ func NewUSDCPayment(payment types.X402Payment) (*Payment, error) {
89
}, nil
90
}
91
92
+// USDCAmountToAtomic converts a human USDC amount, such as "0.01", to atomic
93
+// units for the x402 facilitator. USDC has 6 decimals.
94
+func USDCAmountToAtomic(amount string) (string, error) {
95
+ amount = strings.TrimSpace(amount)
96
+ if amount == "" {
97
+ return "", errors.New("x402 USDC payment amount is required")
98
+ }
99
+ d, _, err := new(apd.Decimal).SetString(amount)
100
+ if err != nil {
101
+ return "", fmt.Errorf("x402 USDC payment amount must be a decimal USDC amount: %s", amount)
102
+ }
103
+ d.Exponent += int32(usdcDecimals)
104
+ d.Reduce(d)
105
+ if d.Form != apd.Finite || d.Sign() <= 0 {
106
+ return "", fmt.Errorf("x402 USDC payment amount must be positive: %s", amount)
107
+ }
108
+ if d.Exponent < 0 {
109
+ return "", fmt.Errorf("x402 USDC payment amount supports up to %d decimals: %s", usdcDecimals, amount)
110
+ }
111
+ return fmt.Sprintf("%f", d), nil
112
+}
113
+
114
+// FormatUSDCAtomicAmount renders an atomic USDC amount as a human amount.
115
+func FormatUSDCAtomicAmount(amount string) string {
116
+ amount = strings.TrimSpace(amount)
117
+ if amount == "" {
118
+ return ""
119
+ }
120
+ d, _, err := new(apd.Decimal).SetString(amount)
121
+ if err != nil {
122
+ return amount + " atomic USDC"
123
+ }
124
+ d.Exponent -= int32(usdcDecimals)
125
+ d.Reduce(d)
126
+ if d.Form != apd.Finite || d.Sign() < 0 {
127
+ return amount + " atomic USDC"
128
+ }
129
+ return fmt.Sprintf("%f USDC", d)
130
+}
131
+
132
func (p *Payment) Verify(ctx context.Context, w http.ResponseWriter, r *http.Request) (*facilitatortypes.PaymentPayload, bool) {
133
if p == nil {
134
http.Error(w, "payment is not configured", http.StatusInternalServerError)
sdk/http.go
+5
@@ -132,6 +132,7 @@ type HTTPRouteConfig struct {
132
// Methods limits payment to these HTTP methods. Empty means every method.
133
Methods []string
134
// Amount enables Sui USDC x402 payment for this public path prefix.
135
+ // It is a human USDC amount such as "0.01"; x402 converts it to atomic units.
136
Amount string
137
}
138
@@ -178,6 +179,10 @@ func (h *HTTPRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) {
179
path = r.URL.Path
180
}
181
path = utils.NormalizeURLPath(path)
182
+ if path == types.X402ClientPath {
183
+ x402.ServeClientJS(w, r)
184
+ return
185
+ }
186
prepare := path == types.X402PreparePath
187
var paymentSender string
188
paymentMethod := http.MethodGet
types/agent.go
+18
-4
@@ -14,12 +14,22 @@ type AgentTunnelStatus struct {
14
State string `json:"state"`
15
TargetAddr string `json:"target_addr,omitempty"`
16
LastError string `json:"last_error,omitempty"`
17
+ Discovery bool `json:"discovery"`
18
MaxActiveRelays int `json:"max_active_relays,omitempty"`
19
Metadata LeaseMetadata `json:"metadata,omitempty"`
20
MultiHop []string `json:"multi_hop,omitempty"`
21
+ X402PayTo string `json:"x402_pay_to,omitempty"`
22
+ HTTPRoutes []AgentHTTPRoute `json:"http_routes,omitempty"`
23
Relays []AgentRelayStatus `json:"relays,omitempty"`
24
}
25
26
+type AgentHTTPRoute struct {
27
+ Prefix string `json:"prefix"`
28
+ Upstream string `json:"upstream"`
29
+ Methods []string `json:"methods,omitempty"`
30
+ Amount string `json:"amount,omitempty"`
31
+}
32
+
33
type AgentRelayStatus struct {
34
RelayURL string `json:"relay_url"`
35
PublicURL string `json:"public_url,omitempty"`
@@ -34,10 +44,14 @@ type AgentRelayStatus struct {
44
}
45
46
type AgentTunnelRequest struct {
37
- ID string `json:"id"`
38
- Name string `json:"name,omitempty"`
39
- TargetAddr string `json:"target_addr,omitempty"`
40
- RelayURLs []string `json:"relays,omitempty"`
47
+ ID string `json:"id"`
48
+ Name string `json:"name,omitempty"`
49
+ TargetAddr string `json:"target_addr,omitempty"`
50
+ HTTPRoutes []AgentHTTPRoute `json:"http_routes,omitempty"`
51
+ RelayURLs []string `json:"relays,omitempty"`
52
+ Discovery *bool `json:"discovery,omitempty"`
53
+ MaxActiveRelays int `json:"max_active_relays,omitempty"`
54
+ X402PayTo string `json:"x402_pay_to,omitempty"`
55
}
56
57
type AgentRelayRequest struct {
types/paths.go
+1
@@ -44,6 +44,7 @@ const (
44
45
const (
46
X402PreparePath = "/x402/prepare"
47
+ X402ClientPath = "/x402/client.js"
48
49
PathAgentPrefix = "/agent"
50
PathAgentStatus = PathAgentPrefix + "/status"