Refactor HTTP routing and payment handling
- Updated HTTP route handling to support new configuration structure with `HTTPRouteConfig`. - Introduced `NewHTTPRoutes` function to create HTTP route handlers. - Removed deprecated `serveCompressedHTTP` function and related tests. - Implemented `X402Payment` and related types for payment processing. - Added utility functions for payment header management and response handling. - Enhanced request handling to support x402 payment preparation and settlement. - Updated tests to reflect changes in HTTP route handling and removed obsolete tests.
Kim committed
Jun 4, 2026 at 19:21 UTC
2674ad1dbe35eb05565f28f31836959032125a1c
23 files changed
+1019
-1072
cmd/payment-app/handler.go
+51
-205
@@ -1,21 +1,15 @@
1
package main
2
3
import (
4
- "context"
4
"embed"
6
- "encoding/base64"
5
"encoding/json"
8
- "fmt"
6
"html/template"
7
"io/fs"
8
"net/http"
9
"strings"
10
"time"
11
15
- portalx402 "github.com/gosuda/portal-tunnel/v2/portal/x402"
16
- suischeme "github.com/gosuda/x402-facilitator/scheme/sui"
17
- facilitatortypes "github.com/gosuda/x402-facilitator/types"
18
-
12
+ "github.com/gosuda/portal-tunnel/v2/portal/x402"
13
"github.com/gosuda/portal-tunnel/v2/types"
14
"github.com/gosuda/portal-tunnel/v2/utils"
15
)
@@ -167,13 +161,14 @@ type paymentHandlerConfig struct {
161
}
162
163
type paymentHandler struct {
170
- gate *portalx402.Gate
171
- requirements facilitatortypes.PaymentRequirements
172
- metadata types.LeaseMetadata
173
- networkName string
174
- requestTimeout time.Duration
175
- endpoints []string
176
- photoURL string
164
+ metadata types.LeaseMetadata
165
+ network string
166
+ networkName string
167
+ asset string
168
+ amount string
169
+ payTo string
170
+ endpoints []string
171
+ photoURL string
172
}
173
174
type paymentPageData struct {
@@ -192,46 +187,33 @@ type paymentPageData struct {
187
ConfigJSON template.JS
188
}
189
195
-type preparePaymentRequest struct {
196
- Sender string `json:"sender"`
197
-}
198
-
199
-type walletTransaction struct {
200
- Transaction string `json:"transaction"`
201
-}
202
-
203
-type preparePaymentResponse struct {
204
- X402Version int `json:"x402Version"`
205
- PaymentRequirements facilitatortypes.PaymentRequirements `json:"paymentRequirements"`
206
- Resource *facilitatortypes.ResourceInfo `json:"resource,omitempty"`
207
- PrepareTransaction *walletTransaction `json:"prepareTransaction,omitempty"`
208
- PaymentTransaction walletTransaction `json:"paymentTransaction"`
209
-}
210
-
190
func newHandler(cfg paymentHandlerConfig) (http.Handler, error) {
212
- gate, err := portalx402.NewUSDCGate(portalx402.GateConfig{
213
- Network: portalx402.Network(cfg.Testnet),
214
- PayTo: cfg.PayTo,
215
- Amount: cfg.Amount,
216
- MaxTimeoutSeconds: cfg.MaxTimeoutSeconds,
217
- })
191
+ handler := &paymentHandler{
192
+ metadata: cfg.Metadata.Copy(),
193
+ photoURL: strings.TrimSpace(cfg.PhotoURL),
194
+ }
195
+ paidPhotoHandler, err := x402.NewUSDCPaymentHandler(types.X402Payment{
196
+ Testnet: cfg.Testnet,
197
+ PayTo: cfg.PayTo,
198
+ Amount: cfg.Amount,
199
+ MaxTimeoutSeconds: cfg.MaxTimeoutSeconds,
200
+ RequestTimeout: cfg.RequestTimeout,
201
+ Endpoints: cfg.Endpoints,
202
+ ResourcePath: paidPhotoPath,
203
+ ResourceDescription: cfg.Metadata.Description,
204
+ ResourceMimeType: "text/html",
205
+ }, paidPhotoPath, http.MethodGet, handler.renderPaidPhoto)
206
if err != nil {
207
return nil, err
208
}
221
- requirements := gate.Requirements()
222
- networkName := portalx402.NetworkDisplayName(requirements.Network)
223
- if networkName == "" {
224
- networkName = requirements.Network
225
- }
226
- handler := &paymentHandler{
227
- gate: gate,
228
- requirements: requirements,
229
- metadata: cfg.Metadata.Copy(),
230
- networkName: networkName,
231
- requestTimeout: cfg.RequestTimeout,
232
- endpoints: append([]string(nil), cfg.Endpoints...),
233
- photoURL: strings.TrimSpace(cfg.PhotoURL),
234
- }
209
+ payment := paidPhotoHandler.Payment()
210
+ handler.network = payment.Network
211
+ handler.networkName = payment.NetworkName
212
+ handler.asset = payment.Asset
213
+ handler.amount = payment.Amount
214
+ handler.payTo = payment.PayTo
215
+ handler.endpoints = append([]string(nil), payment.Endpoints...)
216
+ handler.photoURL = strings.TrimSpace(cfg.PhotoURL)
217
218
staticFS, err := fs.Sub(staticFiles, "static")
219
if err != nil {
@@ -239,9 +221,9 @@ func newHandler(cfg paymentHandlerConfig) (http.Handler, error) {
221
}
222
mux := http.NewServeMux()
223
mux.Handle("/static/style.css", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
242
- mux.HandleFunc("/api/payment/prepare", handler.handlePreparePayment)
224
+ mux.Handle(types.X402PreparePath, paidPhotoHandler)
225
mux.HandleFunc("/", handler.handleIndex)
244
- mux.HandleFunc(paidPhotoPath, handler.handlePaidPhoto)
226
+ mux.Handle(paidPhotoPath, paidPhotoHandler)
227
return mux, nil
228
}
229
@@ -259,134 +241,32 @@ func (h *paymentHandler) handleIndex(w http.ResponseWriter, r *http.Request) {
241
_ = indexPage.Execute(w, data)
242
}
243
262
-func (h *paymentHandler) handlePreparePayment(w http.ResponseWriter, r *http.Request) {
263
- if !utils.RequireMethod(w, r, http.MethodPost) {
264
- return
265
- }
266
- var req preparePaymentRequest
267
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
268
- http.Error(w, "invalid payment prepare request", http.StatusBadRequest)
269
- return
270
- }
271
- sender := suischeme.NormalizeAddress(req.Sender)
272
- if sender == "" {
273
- http.Error(w, "sender is required", http.StatusBadRequest)
274
- return
275
- }
276
-
277
- ctx, cancel := h.requestContext(r)
278
- defer cancel()
279
-
280
- requirements := h.requirements
281
- coinObjects, err := suischeme.ListOwnedGaslessStablecoinCoinObjects(ctx, requirements.Network, sender, requirements.Asset, h.endpoints)
282
- if err != nil {
283
- http.Error(w, fmt.Sprintf("list USDC coin objects: %v", err), http.StatusBadGateway)
284
- return
285
- }
286
- nonZeroCoinObjects := make([]suischeme.OwnedCoinObject, 0, len(coinObjects))
287
- for _, coinObject := range coinObjects {
288
- if coinObject.Balance == 0 {
289
- continue
290
- }
291
- nonZeroCoinObjects = append(nonZeroCoinObjects, coinObject)
292
- }
293
-
294
- var prepareTransaction *walletTransaction
295
- if len(nonZeroCoinObjects) > 0 {
296
- txBytes, err := suischeme.BuildCoinObjectsToAddressBalanceTransferTransaction(ctx, suischeme.CoinObjectsToAddressBalanceTransfer{
297
- Sender: sender,
298
- Recipient: sender,
299
- Network: requirements.Network,
300
- Asset: requirements.Asset,
301
- CoinObjects: nonZeroCoinObjects,
302
- Endpoints: h.endpoints,
303
- })
304
- if err != nil {
305
- http.Error(w, fmt.Sprintf("build prepare transaction: %v", err), http.StatusBadGateway)
306
- return
307
- }
308
- prepareTransaction = &walletTransaction{Transaction: base64.StdEncoding.EncodeToString(txBytes)}
309
- }
310
-
311
- paymentTxBytes, err := suischeme.BuildGaslessStablecoinTransferTransaction(ctx, suischeme.GaslessStablecoinTransfer{
312
- Sender: sender,
313
- Recipient: requirements.PayTo,
314
- Network: requirements.Network,
315
- Asset: requirements.Asset,
316
- Amount: requirements.Amount,
317
- Endpoints: h.endpoints,
318
- })
319
- if err != nil {
320
- http.Error(w, fmt.Sprintf("build payment transaction: %v", err), http.StatusBadGateway)
321
- return
322
- }
323
-
324
- writeJSON(w, http.StatusOK, preparePaymentResponse{
325
- X402Version: int(facilitatortypes.X402VersionV2),
326
- PaymentRequirements: requirements,
327
- Resource: &facilitatortypes.ResourceInfo{
328
- URL: publicURLForPath(r, paidPhotoPath),
329
- Description: h.metadata.Description,
330
- MimeType: "text/html",
331
- },
332
- PrepareTransaction: prepareTransaction,
333
- PaymentTransaction: walletTransaction{Transaction: base64.StdEncoding.EncodeToString(paymentTxBytes)},
334
- })
335
-}
336
-
337
-func (h *paymentHandler) handlePaidPhoto(w http.ResponseWriter, r *http.Request) {
338
- if r.URL.Path != paidPhotoPath {
339
- http.NotFound(w, r)
340
- return
341
- }
342
- if !utils.RequireMethod(w, r, http.MethodGet) {
343
- return
344
- }
345
-
346
- ctx, cancel := h.requestContext(r)
347
- defer cancel()
348
-
349
- payment, err := h.gate.VerifyRequest(ctx, r)
350
- if err != nil {
351
- h.gate.WriteRequestError(w, r, err)
352
- return
353
- }
354
- settled, err := h.gate.SettleVerifiedPayment(ctx, payment)
355
- if err != nil {
356
- h.gate.WritePaymentRequired(w, r, "payment settlement failed")
357
- return
358
- }
359
- portalx402.SetPaymentResponseHeaders(w.Header(), settled)
360
-
244
+func (h *paymentHandler) renderPaidPhoto(w http.ResponseWriter, r *http.Request, result types.X402PaymentResult) {
245
data := h.newPaymentPageData(r)
362
- data.URL = publicURLForPath(r, paidPhotoPath)
363
- data.TransactionID = strings.TrimSpace(settled.Transaction)
246
+ data.URL = utils.PublicURLForPath(r, paidPhotoPath)
247
+ data.TransactionID = result.TransactionID
248
w.Header().Set("Content-Type", "text/html; charset=utf-8")
249
w.Header().Set("Cache-Control", "no-store")
250
_ = photoPage.Execute(w, data)
251
}
252
369
-func (h *paymentHandler) requestContext(r *http.Request) (context.Context, context.CancelFunc) {
370
- if h.requestTimeout <= 0 {
371
- return r.Context(), func() {}
372
- }
373
- return context.WithTimeout(r.Context(), h.requestTimeout)
374
-}
375
-
253
func (h *paymentHandler) newPaymentPageData(r *http.Request) paymentPageData {
377
- requirements := h.requirements
254
description := strings.TrimSpace(h.metadata.Description)
255
if description == "" {
256
description = "Connect a Sui wallet, settle USDC with x402, and reveal the protected image."
257
}
382
- config := map[string]string{
383
- "network": requirements.Network,
258
+ config := map[string]any{
259
+ "network": h.network,
260
"networkName": h.networkName,
385
- "asset": requirements.Asset,
386
- "amount": requirements.Amount,
387
- "payTo": requirements.PayTo,
261
+ "asset": h.asset,
262
+ "amount": h.amount,
263
+ "payTo": h.payTo,
264
+ "preparePath": types.X402PreparePath,
265
"protectedPath": paidPhotoPath,
266
}
267
+ if len(h.endpoints) > 0 {
268
+ config["endpoints"] = append([]string(nil), h.endpoints...)
269
+ }
270
configJSON, err := json.Marshal(config)
271
if err != nil {
272
configJSON = []byte("{}")
@@ -394,49 +274,15 @@ func (h *paymentHandler) newPaymentPageData(r *http.Request) paymentPageData {
274
return paymentPageData{
275
PageTitle: "Portal Sui Wallet Payment",
276
PageDescription: description,
397
- URL: publicURLForPath(r, "/"),
277
+ URL: utils.PublicURLForPath(r, "/"),
278
OGImage: h.photoURL,
279
ProtectedPath: paidPhotoPath,
400
- Network: requirements.Network,
280
+ Network: h.network,
281
NetworkName: h.networkName,
402
- Asset: requirements.Asset,
403
- Amount: requirements.Amount,
282
+ Asset: h.asset,
283
+ Amount: h.amount,
284
PhotoURL: h.photoURL,
405
- RecipientAddress: requirements.PayTo,
285
+ RecipientAddress: h.payTo,
286
ConfigJSON: template.JS(string(configJSON)),
287
}
288
}
409
-
410
-func publicURLForPath(r *http.Request, path string) string {
411
- if r == nil {
412
- return ""
413
- }
414
- scheme, _, _ := strings.Cut(r.Header.Get("X-Forwarded-Proto"), ",")
415
- scheme = strings.ToLower(strings.TrimSpace(scheme))
416
- if scheme == "" {
417
- if r.TLS != nil {
418
- scheme = "https"
419
- } else {
420
- scheme = "http"
421
- }
422
- }
423
- host, _, _ := strings.Cut(r.Header.Get("X-Forwarded-Host"), ",")
424
- host = strings.TrimSpace(host)
425
- if host == "" {
426
- host = strings.TrimSpace(r.Host)
427
- }
428
- if host == "" {
429
- return path
430
- }
431
- if !strings.HasPrefix(path, "/") {
432
- path = "/" + path
433
- }
434
- return scheme + "://" + host + path
435
-}
436
-
437
-func writeJSON(w http.ResponseWriter, status int, value any) {
438
- w.Header().Set("Content-Type", "application/json")
439
- w.Header().Set("Cache-Control", "no-store")
440
- w.WriteHeader(status)
441
- _ = json.NewEncoder(w).Encode(value)
442
-}
cmd/payment-app/main.go
+2
-2
@@ -66,7 +66,7 @@ func run(args []string) error {
66
utils.StringFlagEnv(fs, &cfg.identityJSON, "identity-json", "", "identity json payload; overrides --identity-path contents and is persisted there when both are set", "IDENTITY_JSON")
67
utils.IntFlagEnv(fs, &cfg.maxActiveRelays, "max-active-relays", 3, nil, "maximum number of auto-selected relays to keep connected; explicit --relays are always included", "MAX_ACTIVE_RELAYS")
68
utils.StringFlag(fs, &cfg.addr, "addr", "127.0.0.1:8093", "local payment app HTTP listen address (host:port or URL)")
69
- utils.StringFlag(fs, &cfg.name, "name", "payment-app2", "public hostname prefix (single DNS label)")
69
+ utils.StringFlag(fs, &cfg.name, "name", "payment-app3", "public hostname prefix (single DNS label)")
70
utils.StringFlag(fs, &cfg.desc, "description", "Portal Sui wallet x402 payment app", "lease description")
71
utils.StringFlag(fs, &cfg.tags, "tags", "payment,x402,sui,usdc,image,photo", "comma-separated lease tags")
72
utils.StringFlag(fs, &cfg.owner, "owner", "PortalApp Developer", "lease owner")
@@ -74,7 +74,7 @@ func run(args []string) error {
74
utils.StringFlag(fs, &cfg.photoURL, "photo-url", defaultPhotoURL, "image URL revealed after payment")
75
utils.BoolFlag(fs, &cfg.hide, "hide", false, "hide this lease from listings")
76
utils.BoolFlag(fs, &cfg.x402Testnet, "x402-testnet", true, "use Sui testnet for x402 payments")
77
- utils.StringFlag(fs, &cfg.x402PayTo, "x402-pay-to", "", "Sui USDC recipient address")
77
+ utils.StringFlag(fs, &cfg.x402PayTo, "x402-pay-to", "0xea049676e91d29270f5a95042b6da73ff918fbae19377c0a8bf18ad105e88663", "Sui USDC recipient address")
78
utils.StringFlag(fs, &cfg.x402Amount, "x402-amount", "10000", "USDC amount in atomic units")
79
utils.RepeatedStringFlag(fs, &cfg.x402RPCs, "x402-rpc", "Sui RPC endpoint; repeat to try multiple endpoints before defaults")
80
fs.IntVar(&cfg.x402MaxTimeoutSeconds, "x402-max-timeout", 0, "x402 max payment timeout seconds advertised to clients")
cmd/payment-app/static/index.html
+103
-3
@@ -167,6 +167,100 @@
167
return Transaction.from(base64ToBytes(value));
168
}
169
170
+ function suiRpcURL() {
171
+ const endpoints = Array.isArray(config.endpoints) ? config.endpoints.filter(Boolean) : [];
172
+ const defaults = {
173
+ 'sui:mainnet': 'https://sui-rpc.publicnode.com',
174
+ 'sui:testnet': 'https://sui-testnet-rpc.publicnode.com',
175
+ };
176
+ return endpoints[0] || defaults[String(config.network || '').trim().toLowerCase()] || defaults['sui:testnet'];
177
+ }
178
+
179
+ function transactionDigest(value) {
180
+ if (!value || typeof value !== 'object') {
181
+ return '';
182
+ }
183
+ const stack = [value];
184
+ while (stack.length > 0) {
185
+ const current = stack.pop();
186
+ if (!current || typeof current !== 'object') {
187
+ continue;
188
+ }
189
+ for (const [key, nested] of Object.entries(current)) {
190
+ if ((key === 'digest' || key === 'transactionDigest') && typeof nested === 'string') {
191
+ return nested;
192
+ }
193
+ if (nested && typeof nested === 'object') {
194
+ stack.push(nested);
195
+ }
196
+ }
197
+ }
198
+ return '';
199
+ }
200
+
201
+ function sleep(ms) {
202
+ return new Promise((resolve) => setTimeout(resolve, ms));
203
+ }
204
+
205
+ async function suiRPC(method, params) {
206
+ const response = await fetch(suiRpcURL(), {
207
+ method: 'POST',
208
+ headers: { 'Content-Type': 'application/json' },
209
+ body: JSON.stringify({
210
+ jsonrpc: '2.0',
211
+ id: 1,
212
+ method,
213
+ params,
214
+ }),
215
+ });
216
+ if (!response.ok) {
217
+ throw new Error(`Sui RPC returned ${response.status}`);
218
+ }
219
+ const body = await response.json();
220
+ if (body.error) {
221
+ throw new Error(body.error.message || 'Sui RPC error');
222
+ }
223
+ return body.result;
224
+ }
225
+
226
+ async function waitForTransaction(value) {
227
+ const digest = transactionDigest(value);
228
+ if (!digest) {
229
+ return;
230
+ }
231
+
232
+ const deadline = Date.now() + 20000;
233
+ let lastError = null;
234
+ for (;;) {
235
+ let result = null;
236
+ try {
237
+ result = await suiRPC('sui_getTransactionBlock', [
238
+ digest,
239
+ { showEffects: true },
240
+ ]);
241
+ } catch (error) {
242
+ lastError = error;
243
+ if (Date.now() >= deadline) {
244
+ throw lastError;
245
+ }
246
+ await sleep(1000);
247
+ }
248
+ if (!result?.digest) {
249
+ lastError = new Error('Prepare transaction is not indexed yet');
250
+ if (Date.now() >= deadline) {
251
+ throw lastError;
252
+ }
253
+ await sleep(1000);
254
+ continue;
255
+ }
256
+ const status = result.effects?.status?.status;
257
+ if (status && status !== 'success') {
258
+ throw new Error(result.effects.status.error || 'Prepare transaction failed');
259
+ }
260
+ return;
261
+ }
262
+ }
263
+
264
async function signTransaction(wallet, account, transactionBytes) {
265
const tx = transactionFromBase64(transactionBytes);
266
if (wallet.features['sui:signTransaction']) {
@@ -207,10 +301,14 @@
301
}
302
303
async function preparePayment(sender) {
210
- const response = await fetch('/api/payment/prepare', {
304
+ const response = await fetch(config.preparePath, {
305
method: 'POST',
306
headers: { 'Content-Type': 'application/json' },
213
- body: JSON.stringify({ sender }),
307
+ body: JSON.stringify({
308
+ sender,
309
+ method: 'GET',
310
+ path: config.protectedPath,
311
+ }),
312
});
313
if (!response.ok) {
314
throw new Error(await response.text());
@@ -236,7 +334,9 @@
334
const prepared = await preparePayment(account.address);
335
if (prepared.prepareTransaction) {
336
setStatus('Preparing object balance in wallet');
239
- await executePrepareTransaction(wallet, account, prepared.prepareTransaction.transaction);
337
+ const preparedResult = await executePrepareTransaction(wallet, account, prepared.prepareTransaction.transaction);
338
+ setStatus('Waiting for prepared balance');
339
+ await waitForTransaction(preparedResult);
340
}
341
342
setStatus('Signing x402 payment');
cmd/portal-tunnel/README.md
+3
-3
@@ -153,7 +153,7 @@ Mode constraints:
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-price` applies only to routed HTTP prefixes and requires a
156
+- `--x402-amount` applies only to routed HTTP prefixes and requires a
157
tunnel-owned `--x402-pay-to`.
158
159
Common flags:
@@ -174,7 +174,7 @@ Common flags:
174
--owner Service owner metadata
175
--hide Hide service from relay listing screens
176
--x402-pay-to Sui USDC payment recipient address for this tunnel
177
---x402-price Sui USDC x402 price mapping in PATH=ATOMIC_AMOUNT form; repeatable
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
179
--tcp Request a dedicated raw TCP port on the relay
180
--udp Enable public UDP relay in addition to the default stream path
@@ -277,7 +277,7 @@ 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", x402_price = "100000" },
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
```
cmd/portal-tunnel/agent/config.go
+11
-6
@@ -63,9 +63,10 @@ type TunnelConfig struct {
63
}
64
65
type HTTPRouteConfig struct {
66
- Prefix string `koanf:"prefix"`
67
- Upstream string `koanf:"upstream"`
68
- X402Price string `koanf:"x402_price"`
66
+ Prefix string `koanf:"prefix"`
67
+ Upstream string `koanf:"upstream"`
68
+ Methods []string `koanf:"methods"`
69
+ Amount string `koanf:"amount"`
70
}
71
72
func LoadExistingConfig(path string) (Config, error) {
@@ -179,7 +180,8 @@ func tunnelConfigDocumentMap(cfg TunnelConfig) map[string]any {
180
routeMap := make(map[string]any)
181
addStringDocumentField(routeMap, "prefix", route.Prefix)
182
addStringDocumentField(routeMap, "upstream", route.Upstream)
182
- addStringDocumentField(routeMap, "x402_price", route.X402Price)
183
+ addStringSliceDocumentField(routeMap, "methods", route.Methods)
184
+ addStringDocumentField(routeMap, "amount", route.Amount)
185
routes = append(routes, routeMap)
186
}
187
out["http_routes"] = routes
@@ -359,8 +361,11 @@ func (cfg TunnelConfig) Validate() error {
361
if strings.TrimSpace(route.Prefix) == "" || strings.TrimSpace(route.Upstream) == "" {
362
return fmt.Errorf("tunnel %q http_routes require prefix and upstream", cfg.ID)
363
}
362
- if strings.TrimSpace(route.X402Price) != "" && strings.TrimSpace(cfg.X402PayTo) == "" {
363
- return fmt.Errorf("tunnel %q http route %q x402_price requires x402_pay_to", cfg.ID, strings.TrimSpace(route.Prefix))
364
+ if strings.TrimSpace(route.Amount) != "" && strings.TrimSpace(cfg.X402PayTo) == "" {
365
+ return fmt.Errorf("tunnel %q http route %q amount requires x402_pay_to", cfg.ID, strings.TrimSpace(route.Prefix))
366
+ }
367
+ if strings.TrimSpace(route.Amount) == "" && len(route.Methods) > 0 {
368
+ return fmt.Errorf("tunnel %q http route %q methods require amount", cfg.ID, strings.TrimSpace(route.Prefix))
369
}
370
}
371
return nil
cmd/portal-tunnel/agent/manager.go
+6
-5
@@ -682,12 +682,13 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
682
defer exposure.Close()
683
684
if len(cfg.HTTPRoutes) > 0 {
685
- routes := make([]sdk.HTTPRoute, 0, len(cfg.HTTPRoutes))
685
+ routes := make([]sdk.HTTPRouteConfig, 0, len(cfg.HTTPRoutes))
686
for _, route := range cfg.HTTPRoutes {
687
- routes = append(routes, sdk.HTTPRoute{
688
- Prefix: route.Prefix,
689
- Upstream: route.Upstream,
690
- X402Price: route.X402Price,
687
+ routes = append(routes, sdk.HTTPRouteConfig{
688
+ Prefix: route.Prefix,
689
+ Upstream: route.Upstream,
690
+ Methods: route.Methods,
691
+ Amount: route.Amount,
692
})
693
}
694
err = exposure.RunHTTPRoutes(ctx, routes, "")
cmd/portal-tunnel/main.go
+44
-25
@@ -60,7 +60,7 @@ type exposeFlags struct {
60
thumbnail string
61
hide bool
62
x402PayTo string
63
- x402Prices []string
63
+ x402Amounts []string
64
targetAddr string
65
httpRoutes []string
66
udp bool
@@ -90,7 +90,7 @@ func runExposeCommand(args []string) error {
90
utils.StringFlag(fs, &flags.thumbnail, "thumbnail", "", "Service thumbnail URL metadata")
91
utils.BoolFlag(fs, &flags.hide, "hide", false, "Hide service from relay listing screens")
92
utils.StringFlag(fs, &flags.x402PayTo, "x402-pay-to", "", "Sui USDC payment recipient address for this tunnel")
93
- utils.RepeatedStringFlag(fs, &flags.x402Prices, "x402-price", "Sui USDC x402 price mapping in PATH=ATOMIC_AMOUNT form; repeat to price multiple HTTP routes")
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")
95
utils.BoolFlagEnv(fs, &flags.udp, "udp", false, "Enable public UDP relay in addition to the default TCP relay", "UDP_ENABLED")
96
utils.StringFlagEnv(fs, &flags.udpAddr, "udp-addr", "", "Local UDP target address for relayed datagrams (host:port or port only); defaults to the target when --udp is enabled", "UDP_ADDR")
@@ -123,39 +123,57 @@ func runExposeCommand(args []string) error {
123
case len(httpRouteInputs) > 0 && flags.udp:
124
printExposeUsage(os.Stderr)
125
return errors.New("--udp cannot be combined with --http-route")
126
- case len(flags.x402Prices) > 0 && len(httpRouteInputs) == 0:
126
+ case len(flags.x402Amounts) > 0 && len(httpRouteInputs) == 0:
127
printExposeUsage(os.Stderr)
128
- return errors.New("--x402-price requires --http-route")
129
- case len(flags.x402Prices) > 0 && strings.TrimSpace(flags.x402PayTo) == "":
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-price requires --x402-pay-to")
131
+ return errors.New("--x402-amount requires --x402-pay-to")
132
}
133
134
- x402Prices := make(map[string]string, len(flags.x402Prices))
135
- for _, raw := range flags.x402Prices {
136
- prefix, price, ok := strings.Cut(raw, "=")
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 {
138
- return fmt.Errorf("--x402-price %q: expected PATH=ATOMIC_AMOUNT", raw)
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 == "" {
142
- return fmt.Errorf("--x402-price %q: path is required", raw)
160
+ return fmt.Errorf("--x402-amount %q: path is required", raw)
161
}
162
if !strings.HasPrefix(prefix, "/") {
145
- return fmt.Errorf("--x402-price %q: path must start with /", raw)
163
+ return fmt.Errorf("--x402-amount %q: path must start with /", raw)
164
}
165
prefix = utils.NormalizeURLPath(prefix)
148
- price = strings.TrimSpace(price)
149
- if price == "" {
150
- return fmt.Errorf("--x402-price %q: price is required", raw)
166
+ amount = strings.TrimSpace(amount)
167
+ if amount == "" {
168
+ return fmt.Errorf("--x402-amount %q: amount is required", raw)
169
}
152
- if _, exists := x402Prices[prefix]; exists {
153
- return fmt.Errorf("--x402-price path %q repeated", prefix)
170
+ if _, exists := x402Amounts[prefix]; exists {
171
+ return fmt.Errorf("--x402-amount path %q repeated", prefix)
172
}
155
- x402Prices[prefix] = price
173
+ x402Amounts[prefix] = x402AmountRule{methods: methods, amount: amount}
174
}
175
158
- httpRoutes := make([]sdk.HTTPRoute, 0, len(httpRouteInputs))
176
+ httpRoutes := make([]sdk.HTTPRouteConfig, 0, len(httpRouteInputs))
177
for _, raw := range httpRouteInputs {
178
prefix, upstream, ok := strings.Cut(raw, "=")
179
if !ok {
@@ -173,18 +191,19 @@ func runExposeCommand(args []string) error {
191
return fmt.Errorf("--http-route %q: upstream is required", raw)
192
}
193
normalizedPrefix := utils.NormalizeURLPath(prefix)
176
- route := sdk.HTTPRoute{
194
+ route := sdk.HTTPRouteConfig{
195
Prefix: prefix,
196
Upstream: upstream,
197
}
180
- if price, ok := x402Prices[normalizedPrefix]; ok {
181
- route.X402Price = price
182
- delete(x402Prices, normalizedPrefix)
198
+ if payment, ok := x402Amounts[normalizedPrefix]; ok {
199
+ route.Methods = payment.methods
200
+ route.Amount = payment.amount
201
+ delete(x402Amounts, normalizedPrefix)
202
}
203
httpRoutes = append(httpRoutes, route)
204
}
186
- for prefix := range x402Prices {
187
- return fmt.Errorf("--x402-price path %q has no matching --http-route", prefix)
205
+ for prefix := range x402Amounts {
206
+ return fmt.Errorf("--x402-amount path %q has no matching --http-route", prefix)
207
}
208
209
ctx, stop := utils.SignalContext()
docs/src/routes/cli-reference/+page.md
+3
-3
@@ -98,7 +98,7 @@ not supported.
98
| `--owner` | string | | Service owner metadata |
99
| `--hide` | bool | `false` | Hide service from relay listing screens |
100
| `--x402-pay-to` | string | | Sui USDC payment recipient address for this tunnel |
101
-| `--x402-price` | string | | Sui USDC x402 price mapping in `PATH=ATOMIC_AMOUNT` form; repeatable; requires `--http-route` and `--x402-pay-to` |
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
| `--tcp` | bool | `false` | Request a dedicated raw TCP port on the relay |
104
| `--udp` | bool | `false` | Enable public UDP relay in addition to the default stream path |
@@ -112,7 +112,7 @@ not supported.
112
- Explicit `--multi-hop` cannot be combined with automatic `--multi-hop-depth`.
113
- Multi-hop currently supports only the default SNI TLS stream transport.
114
- `--tcp` and `--udp` require matching transport support on the relay.
115
-- `--x402-price` applies only to routed HTTP prefixes and requires a
115
+- `--x402-amount` applies only to routed HTTP prefixes and requires a
116
tunnel-owned `--x402-pay-to`.
117
118
### Examples
@@ -182,7 +182,7 @@ portal expose --name myapp \
182
--http-route /api=http://127.0.0.1:3001 \
183
--http-route /=http://127.0.0.1:5173 \
184
--x402-pay-to 0x... \
185
- --x402-price /api=100000
185
+ --x402-amount /api=100000
186
```
187
188
## `portal list`
docs/src/routes/configuration/+page.md
+6
-4
@@ -163,7 +163,7 @@ The `portal expose` subcommand accepts the following flags. Flags that read from
163
| `--thumbnail` | | string | | Service thumbnail URL metadata |
164
| `--hide` | | bool | `false` | Hide service from relay listing screens |
165
| `--x402-pay-to` | | string | | Sui USDC payment recipient address for this tunnel |
166
-| `--x402-price` | | string | | Sui USDC x402 price mapping in `PATH=ATOMIC_AMOUNT` form; repeatable; requires `--http-route` and `--x402-pay-to` |
166
+| `--x402-amount` | | string | | Sui USDC x402 amount mapping in `[METHOD[,METHOD...]:]PATH=ATOMIC_AMOUNT` form; repeatable; requires `--http-route` and `--x402-pay-to` |
167
168
### Routing
169
@@ -226,7 +226,8 @@ x402_pay_to = "0x..."
226
[[tunnels.http_routes]]
227
prefix = "/api"
228
upstream = "http://127.0.0.1:3001"
229
-x402_price = "100000"
229
+methods = ["GET"]
230
+amount = "100000"
231
232
[[tunnels.http_routes]]
233
prefix = "/"
@@ -261,8 +262,9 @@ Tunnel fields mirror `portal expose` flags:
262
| `identity_json` | string | Identity JSON payload; overrides `identity_path` contents and is persisted there when both are set |
263
| `udp`, `udp_addr`, `tcp` | bool/string | UDP and raw TCP relay options |
264
| `description`, `tags`, `owner`, `thumbnail`, `hide` | mixed | Lease metadata shown by relays |
264
-| `x402_pay_to` | string | Tunnel-owned Sui USDC x402 payment recipient for priced HTTP routes |
265
-| `http_routes[].x402_price` | string | Optional Sui USDC x402 atomic amount for one HTTP route prefix; requires `x402_pay_to` |
265
+| `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` |
267
+| `http_routes[].methods` | string array | Optional HTTP methods that require payment on that route; empty means every method |
268
For a task-oriented walkthrough, see [Portal Agent](/portal-agent).
269
270
### `identity.json`
docs/src/routes/portal-agent/+page.md
+7
-4
@@ -75,7 +75,8 @@ x402_pay_to = "0x..."
75
[[tunnels.http_routes]]
76
prefix = "/api"
77
upstream = "http://127.0.0.1:3001"
78
-x402_price = "100000"
78
+methods = ["GET"]
79
+amount = "100000"
80
81
[[tunnels.http_routes]]
82
prefix = "/"
@@ -174,8 +175,9 @@ Common fields:
175
| `multi_hop_depth` | Automatically choose one multi-hop route with this depth |
176
| `ban_mitm` | Ban relays when the TLS self-probe detects termination; defaults to warning-only |
177
| `description`, `tags`, `owner`, `thumbnail`, `hide` | Public relay metadata |
177
-| `x402_pay_to` | Tunnel-owned Sui USDC x402 recipient for priced HTTP routes |
178
-| `http_routes[].x402_price` | Optional Sui USDC x402 atomic amount for one HTTP route prefix |
178
+| `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 |
180
+| `http_routes[].methods` | Optional HTTP methods that require payment on that route; empty means every method |
181
182
Constraints match `portal expose`:
183
@@ -185,7 +187,8 @@ Constraints match `portal expose`:
187
- `multi_hop` cannot be combined with `multi_hop_depth`.
188
- Multi-hop currently supports only the default stream transport, not UDP or raw
189
TCP port mode.
188
-- `http_routes[].x402_price` requires `x402_pay_to`.
190
+- `http_routes[].amount` requires `x402_pay_to`.
191
+- `http_routes[].methods` requires `http_routes[].amount`.
192
193
## Identity Layout
194
go.mod
-1
@@ -4,7 +4,6 @@ go 1.26.3
4
5
require (
6
cloud.google.com/go/compute/metadata v0.9.0
7
- github.com/andybalholm/brotli v1.2.1
7
github.com/aws/aws-sdk-go-v2 v1.41.5
8
github.com/aws/aws-sdk-go-v2/config v1.32.14
9
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
go.sum
-2
@@ -21,8 +21,6 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko
21
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
22
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
23
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
24
-github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
25
-github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
24
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
25
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
26
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
portal/x402/handler.go
new
+122
@@ -0,0 +1,122 @@
1
+package x402
2
+
3
+import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "net/http"
9
+ "strings"
10
+
11
+ "github.com/gosuda/portal-tunnel/v2/types"
12
+ "github.com/gosuda/portal-tunnel/v2/utils"
13
+)
14
+
15
+// USDCPaymentHandler serves both the wallet prepare endpoint and one protected resource.
16
+type USDCPaymentHandler struct {
17
+ payment *Payment
18
+ protectedPath string
19
+ method string
20
+ handler types.X402PaymentHandlerFunc
21
+}
22
+
23
+// NewUSDCPaymentHandler returns a complete HTTP handler for one Sui USDC x402 payment flow.
24
+func NewUSDCPaymentHandler(payment types.X402Payment, protectedPath, protectedMethod string, handler types.X402PaymentHandlerFunc) (*USDCPaymentHandler, error) {
25
+ if handler == nil {
26
+ return nil, errors.New("USDC payment handler is required")
27
+ }
28
+ paid, err := NewUSDCPayment(payment)
29
+ if err != nil {
30
+ return nil, err
31
+ }
32
+ payment = paid.payment
33
+
34
+ protectedPath = strings.TrimSpace(protectedPath)
35
+ if protectedPath == "" {
36
+ protectedPath = payment.ResourcePath
37
+ }
38
+ if protectedPath == "" {
39
+ return nil, errors.New("USDC payment protected path is required")
40
+ }
41
+ if !strings.HasPrefix(protectedPath, "/") {
42
+ return nil, fmt.Errorf("USDC payment protected path %q must start with /", protectedPath)
43
+ }
44
+ protectedPath = utils.NormalizeURLPath(protectedPath)
45
+ if protectedPath == types.X402PreparePath {
46
+ return nil, fmt.Errorf("USDC payment protected path cannot be %s", types.X402PreparePath)
47
+ }
48
+
49
+ paid.payment.ResourcePath = protectedPath
50
+ return &USDCPaymentHandler{
51
+ payment: paid,
52
+ protectedPath: protectedPath,
53
+ method: strings.TrimSpace(protectedMethod),
54
+ handler: handler,
55
+ }, nil
56
+}
57
+
58
+// Payment returns the normalized payment contract used by this handler.
59
+func (h *USDCPaymentHandler) Payment() types.X402Payment {
60
+ if h == nil || h.payment == nil {
61
+ return types.X402Payment{}
62
+ }
63
+ return h.payment.payment
64
+}
65
+
66
+func (h *USDCPaymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
67
+ if h == nil || h.payment == nil {
68
+ http.Error(w, "payment is not configured", http.StatusInternalServerError)
69
+ return
70
+ }
71
+
72
+ path := "/"
73
+ if r.URL != nil {
74
+ path = r.URL.Path
75
+ }
76
+ switch path {
77
+ case types.X402PreparePath:
78
+ if !utils.RequireMethod(w, r, http.MethodPost) {
79
+ return
80
+ }
81
+ var req types.X402PreparePaymentRequest
82
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
83
+ http.Error(w, "invalid payment prepare request", http.StatusBadRequest)
84
+ return
85
+ }
86
+ h.payment.WritePrepare(w, r, req.Sender, h.protectedPath)
87
+ case h.protectedPath:
88
+ if h.method != "" && !utils.RequireMethod(w, r, h.method) {
89
+ return
90
+ }
91
+ if h.handler == nil {
92
+ http.NotFound(w, r)
93
+ return
94
+ }
95
+
96
+ ctx := r.Context()
97
+ cancel := func() {}
98
+ payment := h.payment.payment
99
+ if payment.RequestTimeout > 0 {
100
+ ctx, cancel = context.WithTimeout(ctx, payment.RequestTimeout)
101
+ }
102
+ defer cancel()
103
+
104
+ paymentPayload, ok := h.payment.Verify(ctx, w, r)
105
+ if !ok {
106
+ return
107
+ }
108
+ settled, ok := h.payment.Settle(ctx, w, r, paymentPayload)
109
+ if !ok {
110
+ return
111
+ }
112
+ utils.SetPaymentResponseHeaders(w.Header(), settled)
113
+
114
+ h.handler(w, r, types.X402PaymentResult{
115
+ TransactionID: strings.TrimSpace(settled.Transaction),
116
+ Network: string(settled.Network),
117
+ Payer: strings.TrimSpace(settled.Payer),
118
+ })
119
+ default:
120
+ http.NotFound(w, r)
121
+ }
122
+}
portal/x402/payment.go
new
+316
@@ -0,0 +1,316 @@
1
+package x402
2
+
3
+import (
4
+ "context"
5
+ "encoding/base64"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "net/http"
10
+ "strconv"
11
+ "strings"
12
+
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"
16
+
17
+ "github.com/gosuda/portal-tunnel/v2/types"
18
+ "github.com/gosuda/portal-tunnel/v2/utils"
19
+)
20
+
21
+// Payment owns one Sui USDC x402 payment contract and its facilitator runtime.
22
+type Payment struct {
23
+ payment types.X402Payment
24
+ facilitator facilitatorcore.Facilitator
25
+ requirements facilitatortypes.PaymentRequirements
26
+}
27
+
28
+func NewUSDCPayment(payment types.X402Payment) (*Payment, error) {
29
+ network := strings.TrimSpace(payment.Network)
30
+ if network == "" {
31
+ network = Network(payment.Testnet)
32
+ }
33
+ network = strings.ToLower(network)
34
+ asset, err := usdcAsset(network)
35
+ if err != nil {
36
+ return nil, err
37
+ }
38
+ payTo := suischeme.NormalizeAddress(payment.PayTo)
39
+ if payTo == "" {
40
+ return nil, errors.New("x402 USDC payment requires a Sui pay-to address")
41
+ }
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)
46
+ }
47
+ maxTimeoutSeconds := payment.MaxTimeoutSeconds
48
+ if maxTimeoutSeconds <= 0 {
49
+ maxTimeoutSeconds = defaultMaxTimeoutSeconds
50
+ }
51
+ requirements := facilitatortypes.PaymentRequirements{
52
+ Scheme: string(facilitatortypes.Exact),
53
+ Network: network,
54
+ Asset: asset,
55
+ Amount: amount,
56
+ PayTo: payTo,
57
+ MaxTimeoutSeconds: maxTimeoutSeconds,
58
+ Extra: map[string]interface{}{
59
+ "asset": "USDC",
60
+ "assetTransferMethod": "sui-gasless-stablecoin-address-balance",
61
+ },
62
+ }
63
+ endpoints := append([]string(nil), payment.Endpoints...)
64
+ facilitator, err := newUSDCFacilitator(requirements.Network, requirements.Asset, endpoints...)
65
+ if err != nil {
66
+ return nil, err
67
+ }
68
+ networkName := NetworkDisplayName(requirements.Network)
69
+ if networkName == "" {
70
+ networkName = requirements.Network
71
+ }
72
+ payment.Testnet = strings.EqualFold(requirements.Network, TestnetNetwork)
73
+ payment.Network = requirements.Network
74
+ payment.NetworkName = networkName
75
+ payment.Asset = requirements.Asset
76
+ payment.PayTo = requirements.PayTo
77
+ payment.Amount = requirements.Amount
78
+ payment.MaxTimeoutSeconds = requirements.MaxTimeoutSeconds
79
+ payment.Endpoints = endpoints
80
+ payment.ResourcePath = strings.TrimSpace(payment.ResourcePath)
81
+ payment.ResourceDescription = strings.TrimSpace(payment.ResourceDescription)
82
+ payment.ResourceMimeType = strings.TrimSpace(payment.ResourceMimeType)
83
+
84
+ return &Payment{
85
+ payment: payment,
86
+ facilitator: facilitator,
87
+ requirements: requirements,
88
+ }, nil
89
+}
90
+
91
+func (p *Payment) Verify(ctx context.Context, w http.ResponseWriter, r *http.Request) (*facilitatortypes.PaymentPayload, bool) {
92
+ if p == nil {
93
+ http.Error(w, "payment is not configured", http.StatusInternalServerError)
94
+ return nil, false
95
+ }
96
+ if p.facilitator == nil {
97
+ http.Error(w, "x402 facilitator is not configured", http.StatusInternalServerError)
98
+ return nil, false
99
+ }
100
+
101
+ rawPayment := ""
102
+ for _, name := range []string{types.HeaderXPayment, types.HeaderPaymentSignature} {
103
+ if value := strings.TrimSpace(r.Header.Get(name)); value != "" {
104
+ rawPayment = value
105
+ break
106
+ }
107
+ }
108
+ if rawPayment == "" {
109
+ p.writePaymentRequired(w, r, "payment required")
110
+ return nil, false
111
+ }
112
+
113
+ var payload *facilitatortypes.PaymentPayload
114
+ var decoded facilitatortypes.PaymentPayload
115
+ if err := json.Unmarshal([]byte(rawPayment), &decoded); err == nil {
116
+ payload = &decoded
117
+ }
118
+ if payload == nil {
119
+ for _, encoding := range []*base64.Encoding{
120
+ base64.StdEncoding,
121
+ base64.RawStdEncoding,
122
+ base64.URLEncoding,
123
+ base64.RawURLEncoding,
124
+ } {
125
+ raw, err := encoding.DecodeString(rawPayment)
126
+ if err != nil {
127
+ continue
128
+ }
129
+ var decoded facilitatortypes.PaymentPayload
130
+ if err := json.Unmarshal(raw, &decoded); err == nil {
131
+ payload = &decoded
132
+ break
133
+ }
134
+ }
135
+ }
136
+ if payload == nil {
137
+ p.writePaymentRequired(w, r, "invalid payment payload")
138
+ return nil, false
139
+ }
140
+ verified, err := p.facilitator.Verify(ctx, payload, &p.requirements)
141
+ if err != nil {
142
+ http.Error(w, "verify x402 payment", http.StatusBadGateway)
143
+ return nil, false
144
+ }
145
+ if verified == nil || !verified.IsValid {
146
+ reason := "invalid payment"
147
+ if verified != nil {
148
+ reason = strings.TrimSpace(verified.InvalidReason)
149
+ if reason == "" {
150
+ reason = strings.TrimSpace(verified.InvalidMessage)
151
+ }
152
+ }
153
+ if reason == "" {
154
+ reason = "invalid payment"
155
+ }
156
+ p.writePaymentRequired(w, r, reason)
157
+ return nil, false
158
+ }
159
+ return payload, true
160
+}
161
+
162
+func (p *Payment) Settle(ctx context.Context, w http.ResponseWriter, r *http.Request, payment *facilitatortypes.PaymentPayload) (*facilitatortypes.PaymentSettleResponse, bool) {
163
+ if p == nil {
164
+ http.Error(w, "payment is not configured", http.StatusInternalServerError)
165
+ return nil, false
166
+ }
167
+ if p.facilitator == nil {
168
+ http.Error(w, "x402 facilitator is not configured", http.StatusInternalServerError)
169
+ return nil, false
170
+ }
171
+ if payment == nil {
172
+ http.Error(w, "x402 payment is missing", http.StatusInternalServerError)
173
+ return nil, false
174
+ }
175
+ settled, err := p.facilitator.Settle(ctx, payment, &p.requirements)
176
+ if err != nil {
177
+ p.writePaymentRequired(w, r, "payment settlement failed")
178
+ return nil, false
179
+ }
180
+ if settled == nil || !settled.Success {
181
+ p.writePaymentRequired(w, r, "payment settlement failed")
182
+ return nil, false
183
+ }
184
+ return settled, true
185
+}
186
+
187
+func (p *Payment) writePaymentRequired(w http.ResponseWriter, r *http.Request, reason string) {
188
+ if p == nil {
189
+ http.Error(w, reason, http.StatusPaymentRequired)
190
+ return
191
+ }
192
+ resourceURL := ""
193
+ if r != nil && r.URL != nil {
194
+ resourceURL = utils.PublicURLForPath(r, r.URL.RequestURI())
195
+ }
196
+ body := struct {
197
+ X402Version int `json:"x402Version"`
198
+ Error string `json:"error,omitempty"`
199
+ Resource *facilitatortypes.ResourceInfo `json:"resource,omitempty"`
200
+ Accepts []facilitatortypes.PaymentRequirements `json:"accepts"`
201
+ }{
202
+ X402Version: int(facilitatortypes.X402VersionV2),
203
+ Error: strings.TrimSpace(reason),
204
+ Resource: &facilitatortypes.ResourceInfo{URL: resourceURL},
205
+ Accepts: []facilitatortypes.PaymentRequirements{p.requirements},
206
+ }
207
+ raw, err := json.Marshal(body)
208
+ if err != nil {
209
+ http.Error(w, "encode x402 payment requirements", http.StatusInternalServerError)
210
+ return
211
+ }
212
+ encoded := base64.StdEncoding.EncodeToString(raw)
213
+ w.Header().Set("Content-Type", "application/json")
214
+ w.Header().Set(types.HeaderPaymentRequired, encoded)
215
+ w.Header().Set(types.HeaderXPaymentRequired, encoded)
216
+ w.WriteHeader(http.StatusPaymentRequired)
217
+ _, _ = w.Write(raw)
218
+}
219
+
220
+func (p *Payment) WritePrepare(w http.ResponseWriter, r *http.Request, sender, resourcePath string) {
221
+ if p == nil {
222
+ http.Error(w, "payment is not configured", http.StatusInternalServerError)
223
+ return
224
+ }
225
+ ctx := r.Context()
226
+ cancel := func() {}
227
+ if p.payment.RequestTimeout > 0 {
228
+ ctx, cancel = context.WithTimeout(ctx, p.payment.RequestTimeout)
229
+ }
230
+ defer cancel()
231
+
232
+ sender = suischeme.NormalizeAddress(sender)
233
+ if sender == "" {
234
+ http.Error(w, "sender is required", http.StatusBadRequest)
235
+ return
236
+ }
237
+ if p.requirements.Network == "" || p.requirements.Asset == "" {
238
+ http.Error(w, "payment is not configured", http.StatusInternalServerError)
239
+ return
240
+ }
241
+
242
+ coinObjects, err := suischeme.ListOwnedGaslessStablecoinCoinObjects(ctx, p.requirements.Network, sender, p.requirements.Asset, p.payment.Endpoints)
243
+ if err != nil {
244
+ http.Error(w, fmt.Sprintf("list USDC coin objects: %v", err), http.StatusBadGateway)
245
+ return
246
+ }
247
+ nonZeroCoinObjects := make([]suischeme.OwnedCoinObject, 0, len(coinObjects))
248
+ for _, coinObject := range coinObjects {
249
+ if coinObject.Balance == 0 {
250
+ continue
251
+ }
252
+ nonZeroCoinObjects = append(nonZeroCoinObjects, coinObject)
253
+ }
254
+
255
+ var prepareTransaction *struct {
256
+ Transaction string `json:"transaction"`
257
+ }
258
+ if len(nonZeroCoinObjects) > 0 {
259
+ txBytes, err := suischeme.BuildCoinObjectsToAddressBalanceTransferTransaction(ctx, suischeme.CoinObjectsToAddressBalanceTransfer{
260
+ Sender: sender,
261
+ Recipient: sender,
262
+ Network: p.requirements.Network,
263
+ Asset: p.requirements.Asset,
264
+ CoinObjects: nonZeroCoinObjects,
265
+ Endpoints: p.payment.Endpoints,
266
+ })
267
+ if err != nil {
268
+ http.Error(w, fmt.Sprintf("build prepare transaction: %v", err), http.StatusBadGateway)
269
+ return
270
+ }
271
+ prepareTransaction = &struct {
272
+ Transaction string `json:"transaction"`
273
+ }{Transaction: base64.StdEncoding.EncodeToString(txBytes)}
274
+ }
275
+
276
+ paymentTxBytes, err := suischeme.BuildGaslessStablecoinTransferTransaction(ctx, suischeme.GaslessStablecoinTransfer{
277
+ Sender: sender,
278
+ Recipient: p.requirements.PayTo,
279
+ Network: p.requirements.Network,
280
+ Asset: p.requirements.Asset,
281
+ Amount: p.requirements.Amount,
282
+ Endpoints: p.payment.Endpoints,
283
+ })
284
+ if err != nil {
285
+ http.Error(w, fmt.Sprintf("build payment transaction: %v", err), http.StatusBadGateway)
286
+ return
287
+ }
288
+
289
+ resourcePath = strings.TrimSpace(resourcePath)
290
+ if resourcePath == "" {
291
+ resourcePath = strings.TrimSpace(p.payment.ResourcePath)
292
+ }
293
+ if resourcePath == "" && r.URL != nil {
294
+ resourcePath = r.URL.Path
295
+ }
296
+ if resourcePath == "" {
297
+ resourcePath = "/"
298
+ }
299
+ resourceMimeType := strings.TrimSpace(p.payment.ResourceMimeType)
300
+ if resourceMimeType == "" {
301
+ resourceMimeType = "text/html"
302
+ }
303
+ utils.WritePaymentJSON(w, http.StatusOK, types.X402PreparePaymentResponse{
304
+ X402Version: int(facilitatortypes.X402VersionV2),
305
+ PaymentRequirements: p.requirements,
306
+ Resource: &facilitatortypes.ResourceInfo{
307
+ URL: utils.PublicURLForPath(r, resourcePath),
308
+ Description: strings.TrimSpace(p.payment.ResourceDescription),
309
+ MimeType: resourceMimeType,
310
+ },
311
+ PrepareTransaction: prepareTransaction,
312
+ PaymentTransaction: struct {
313
+ Transaction string `json:"transaction"`
314
+ }{Transaction: base64.StdEncoding.EncodeToString(paymentTxBytes)},
315
+ })
316
+}
portal/x402/x402.go
+16
-323
@@ -1,19 +1,14 @@
1
package x402
2
3
import (
4
- "context"
5
- "encoding/base64"
6
- "encoding/json"
4
"errors"
5
"fmt"
6
"net/http"
10
- "strconv"
7
"strings"
8
9
facilitatorapi "github.com/gosuda/x402-facilitator/api"
10
facilitatorcore "github.com/gosuda/x402-facilitator/facilitator"
11
suischeme "github.com/gosuda/x402-facilitator/scheme/sui"
16
- facilitatortypes "github.com/gosuda/x402-facilitator/types"
12
13
"github.com/gosuda/portal-tunnel/v2/types"
14
)
@@ -23,10 +18,6 @@ const (
18
TestnetNetwork = "sui:testnet"
19
20
defaultMaxTimeoutSeconds = 60
26
- paymentRequiredHeader = "PAYMENT-REQUIRED"
27
- paymentResponseHeader = "PAYMENT-RESPONSE"
28
- xPaymentHeader = "X-PAYMENT"
29
- paymentSignatureHeader = "PAYMENT-SIGNATURE"
21
)
22
23
var networkDisplayNames = map[string]string{
@@ -53,7 +44,7 @@ func MountFacilitator(mux *http.ServeMux, cfg FacilitatorConfig) error {
44
if mux == nil {
45
return errors.New("x402 facilitator requires an api mux")
46
}
56
- facilitator, err := NewUSDCFacilitator(Network(cfg.Testnet))
47
+ facilitator, err := newUSDCFacilitator(Network(cfg.Testnet), "")
48
if err != nil {
49
return fmt.Errorf("create sui x402 facilitator: %w", err)
50
}
@@ -61,7 +52,7 @@ func MountFacilitator(mux *http.ServeMux, cfg FacilitatorConfig) error {
52
return nil
53
}
54
64
-func USDCAsset(network string) (string, error) {
55
+func usdcAsset(network string) (string, error) {
56
network = strings.ToLower(strings.TrimSpace(network))
57
asset, ok := suischeme.GetGaslessStablecoinType(network, "USDC")
58
if !ok {
@@ -70,324 +61,26 @@ func USDCAsset(network string) (string, error) {
61
return asset, nil
62
}
63
73
-func NewUSDCFacilitator(network string) (facilitatorcore.Facilitator, error) {
64
+func newUSDCFacilitator(network, asset string, endpoints ...string) (facilitatorcore.Facilitator, error) {
65
network = strings.ToLower(strings.TrimSpace(network))
66
if network == "" {
67
network = MainnetNetwork
68
}
78
- asset, err := USDCAsset(network)
79
- if err != nil {
80
- return nil, err
81
- }
82
- return facilitatorcore.NewSuiFacilitatorWithOptions(network, "", "", facilitatorcore.SuiFacilitatorOptions{
83
- GaslessStablecoinTypes: []string{asset},
84
- })
85
-}
86
-
87
-type GateConfig struct {
88
- Network string
89
- PayTo string
90
- Amount string
91
- MaxTimeoutSeconds int
92
-}
93
-
94
-type Gate struct {
95
- facilitator facilitatorcore.Facilitator
96
- network string
97
- asset string
98
- payTo string
99
- amount string
100
- maxTimeoutSeconds int
101
-}
102
-
103
-func NewUSDCGate(cfg GateConfig) (*Gate, error) {
104
- network := strings.ToLower(strings.TrimSpace(cfg.Network))
105
- if network == "" {
106
- network = MainnetNetwork
107
- }
108
- asset, err := USDCAsset(network)
109
- if err != nil {
110
- return nil, err
111
- }
112
- payTo := suischeme.NormalizeAddress(cfg.PayTo)
113
- if payTo == "" {
114
- return nil, errors.New("x402 USDC payment requires a Sui pay-to address")
115
- }
116
- amount := strings.TrimSpace(cfg.Amount)
117
- n, err := strconv.ParseUint(amount, 10, 64)
118
- if err != nil || n == 0 {
119
- return nil, fmt.Errorf("x402 USDC payment amount must be a positive atomic amount: %s", cfg.Amount)
120
- }
121
- facilitator, err := NewUSDCFacilitator(network)
122
- if err != nil {
123
- return nil, err
124
- }
125
- maxTimeoutSeconds := cfg.MaxTimeoutSeconds
126
- if maxTimeoutSeconds <= 0 {
127
- maxTimeoutSeconds = defaultMaxTimeoutSeconds
128
- }
129
- return &Gate{
130
- facilitator: facilitator,
131
- network: network,
132
- asset: asset,
133
- payTo: payTo,
134
- amount: amount,
135
- maxTimeoutSeconds: maxTimeoutSeconds,
136
- }, nil
137
-}
138
-
139
-func (g *Gate) Requirements() facilitatortypes.PaymentRequirements {
140
- if g == nil {
141
- return facilitatortypes.PaymentRequirements{}
142
- }
143
- return facilitatortypes.PaymentRequirements{
144
- Scheme: string(facilitatortypes.Exact),
145
- Network: g.network,
146
- Asset: g.asset,
147
- Amount: g.amount,
148
- PayTo: g.payTo,
149
- MaxTimeoutSeconds: g.maxTimeoutSeconds,
150
- Extra: map[string]interface{}{
151
- "asset": "USDC",
152
- "assetTransferMethod": "sui-gasless-stablecoin-address-balance",
153
- },
154
- }
155
-}
156
-
157
-type VerifiedPayment struct {
158
- Payload facilitatortypes.PaymentPayload
159
- Requirements facilitatortypes.PaymentRequirements
160
-}
161
-
162
-type RequestError struct {
163
- StatusCode int
164
- Reason string
165
- Err error
166
-}
167
-
168
-func (e *RequestError) Error() string {
169
- if e == nil {
170
- return ""
171
- }
172
- if e.Reason != "" {
173
- return e.Reason
174
- }
175
- if e.Err != nil {
176
- return e.Err.Error()
177
- }
178
- return http.StatusText(e.StatusCode)
179
-}
180
-
181
-func (e *RequestError) Unwrap() error {
182
- if e == nil {
183
- return nil
184
- }
185
- return e.Err
186
-}
187
-
188
-func (g *Gate) VerifyRequest(ctx context.Context, r *http.Request) (*VerifiedPayment, error) {
189
- if g == nil || g.facilitator == nil {
190
- return nil, &RequestError{StatusCode: http.StatusInternalServerError, Reason: "x402 payment gate is not configured"}
191
- }
192
- rawPayment := paymentHeader(r.Header)
193
- if rawPayment == "" {
194
- return nil, &RequestError{StatusCode: http.StatusPaymentRequired, Reason: "payment required"}
195
- }
196
- payload, err := DecodePaymentPayload(rawPayment)
197
- if err != nil {
198
- return nil, &RequestError{StatusCode: http.StatusPaymentRequired, Reason: "invalid payment payload", Err: err}
199
- }
200
- requirements := g.Requirements()
201
- verified, err := g.facilitator.Verify(ctx, payload, &requirements)
202
- if err != nil {
203
- return nil, &RequestError{StatusCode: http.StatusBadGateway, Reason: "verify x402 payment", Err: err}
204
- }
205
- if verified == nil || !verified.IsValid {
206
- reason := "invalid payment"
207
- if verified != nil {
208
- reason = strings.TrimSpace(verified.InvalidReason)
209
- if reason == "" {
210
- reason = strings.TrimSpace(verified.InvalidMessage)
211
- }
69
+ if asset == "" {
70
+ var err error
71
+ asset, err = usdcAsset(network)
72
+ if err != nil {
73
+ return nil, err
74
}
213
- if reason == "" {
214
- reason = "invalid payment"
215
- }
216
- return nil, &RequestError{StatusCode: http.StatusPaymentRequired, Reason: reason}
217
- }
218
- return &VerifiedPayment{
219
- Payload: *payload,
220
- Requirements: requirements,
221
- }, nil
222
-}
223
-
224
-func (g *Gate) SettleVerifiedPayment(ctx context.Context, payment *VerifiedPayment) (*facilitatortypes.PaymentSettleResponse, error) {
225
- if g == nil || g.facilitator == nil {
226
- return nil, errors.New("x402 payment gate is not configured")
227
- }
228
- if payment == nil {
229
- return nil, errors.New("x402 payment is missing")
75
}
231
- settled, err := g.facilitator.Settle(ctx, &payment.Payload, &payment.Requirements)
232
- if err != nil {
233
- return nil, err
234
- }
235
- if settled == nil || !settled.Success {
236
- reason := "settlement failed"
237
- if settled != nil {
238
- reason = strings.TrimSpace(settled.ErrorReason)
239
- if reason == "" {
240
- reason = strings.TrimSpace(settled.ErrorMessage)
241
- }
76
+ url := ""
77
+ for _, endpoint := range endpoints {
78
+ if endpoint = strings.TrimSpace(endpoint); endpoint != "" {
79
+ url = endpoint
80
+ break
81
}
243
- return nil, errors.New(reason)
244
- }
245
- return settled, nil
246
-}
247
-
248
-func (g *Gate) WriteRequestError(w http.ResponseWriter, r *http.Request, err error) {
249
- var reqErr *RequestError
250
- if !errors.As(err, &reqErr) {
251
- reqErr = &RequestError{StatusCode: http.StatusInternalServerError, Reason: err.Error(), Err: err}
252
- }
253
- if reqErr.StatusCode != http.StatusPaymentRequired {
254
- http.Error(w, reqErr.Error(), reqErr.StatusCode)
255
- return
256
- }
257
- g.WritePaymentRequired(w, r, reqErr.Error())
258
-}
259
-
260
-func (g *Gate) WritePaymentRequired(w http.ResponseWriter, r *http.Request, reason string) {
261
- body := paymentRequiredBody{
262
- X402Version: int(facilitatortypes.X402VersionV2),
263
- Error: strings.TrimSpace(reason),
264
- Resource: &facilitatortypes.ResourceInfo{
265
- URL: PublicRequestURL(r),
266
- },
267
- Accepts: []facilitatortypes.PaymentRequirements{g.Requirements()},
268
- }
269
- raw, err := json.Marshal(body)
270
- if err != nil {
271
- http.Error(w, "encode x402 payment requirements", http.StatusInternalServerError)
272
- return
273
- }
274
- encoded := base64.StdEncoding.EncodeToString(raw)
275
- w.Header().Set("Content-Type", "application/json")
276
- w.Header().Set(paymentRequiredHeader, encoded)
277
- w.Header().Set("X-"+paymentRequiredHeader, encoded)
278
- w.WriteHeader(http.StatusPaymentRequired)
279
- _, _ = w.Write(raw)
280
-}
281
-
282
-type paymentRequiredBody struct {
283
- X402Version int `json:"x402Version"`
284
- Error string `json:"error,omitempty"`
285
- Resource *facilitatortypes.ResourceInfo `json:"resource,omitempty"`
286
- Accepts []facilitatortypes.PaymentRequirements `json:"accepts"`
287
-}
288
-
289
-func DecodePaymentPayload(value string) (*facilitatortypes.PaymentPayload, error) {
290
- value = strings.TrimSpace(value)
291
- if value == "" {
292
- return nil, errors.New("empty payment payload")
293
- }
294
- candidates := [][]byte{[]byte(value)}
295
- for _, encoding := range []*base64.Encoding{
296
- base64.StdEncoding,
297
- base64.RawStdEncoding,
298
- base64.URLEncoding,
299
- base64.RawURLEncoding,
300
- } {
301
- if decoded, err := encoding.DecodeString(value); err == nil {
302
- candidates = append(candidates, decoded)
303
- }
304
- }
305
- var lastErr error
306
- for _, raw := range candidates {
307
- var payload facilitatortypes.PaymentPayload
308
- if err := json.Unmarshal(raw, &payload); err != nil {
309
- lastErr = err
310
- continue
311
- }
312
- return &payload, nil
313
- }
314
- if lastErr != nil {
315
- return nil, lastErr
316
- }
317
- return nil, errors.New("invalid payment payload")
318
-}
319
-
320
-func SetPaymentResponseHeaders(header http.Header, settled *facilitatortypes.PaymentSettleResponse) {
321
- if header == nil || settled == nil {
322
- return
323
- }
324
- raw, err := json.Marshal(settled)
325
- if err != nil {
326
- return
82
}
328
- encoded := base64.StdEncoding.EncodeToString(raw)
329
- header.Set(paymentResponseHeader, encoded)
330
- header.Set("X-"+paymentResponseHeader, encoded)
331
-}
332
-
333
-func StripPaymentHeaders(header http.Header) {
334
- header.Del(xPaymentHeader)
335
- header.Del(paymentSignatureHeader)
336
- header.Del(paymentRequiredHeader)
337
- header.Del("X-" + paymentRequiredHeader)
338
- header.Del(paymentResponseHeader)
339
- header.Del("X-" + paymentResponseHeader)
340
-}
341
-
342
-func PublicRequestURL(r *http.Request) string {
343
- if r == nil || r.URL == nil {
344
- return ""
345
- }
346
- scheme, _, _ := strings.Cut(r.Header.Get("X-Forwarded-Proto"), ",")
347
- scheme = strings.ToLower(strings.TrimSpace(scheme))
348
- if scheme == "" {
349
- if r.TLS != nil {
350
- scheme = "https"
351
- } else {
352
- scheme = "http"
353
- }
354
- }
355
- host, _, _ := strings.Cut(r.Header.Get("X-Forwarded-Host"), ",")
356
- host = strings.TrimSpace(host)
357
- if host == "" {
358
- host = strings.TrimSpace(r.Host)
359
- }
360
- if host == "" {
361
- return r.URL.RequestURI()
362
- }
363
- return scheme + "://" + host + r.URL.RequestURI()
364
-}
365
-
366
-func paymentHeader(header http.Header) string {
367
- for _, name := range []string{xPaymentHeader, paymentSignatureHeader} {
368
- if value := strings.TrimSpace(header.Get(name)); value != "" {
369
- return value
370
- }
371
- }
372
- return ""
373
-}
374
-
375
-type verifiedPaymentContextKey struct{}
376
-
377
-func ContextWithVerifiedPayment(ctx context.Context, payment *VerifiedPayment) context.Context {
378
- return context.WithValue(ctx, verifiedPaymentContextKey{}, payment)
379
-}
380
-
381
-func VerifiedPaymentFromContext(ctx context.Context) (*VerifiedPayment, bool) {
382
- payment, ok := ctx.Value(verifiedPaymentContextKey{}).(*VerifiedPayment)
383
- return payment, ok && payment != nil
384
-}
385
-
386
-var ErrSettlementFailed = errors.New("x402 settlement failed")
387
-
388
-func SettlementError(err error) error {
389
- if err == nil {
390
- return nil
391
- }
392
- return fmt.Errorf("%w: %v", ErrSettlementFailed, err)
83
+ return facilitatorcore.NewSuiFacilitatorWithOptions(network, url, "", facilitatorcore.SuiFacilitatorOptions{
84
+ GaslessStablecoinTypes: []string{asset},
85
+ })
86
}
sdk/expose.go
+2
-2
@@ -523,9 +523,9 @@ func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
523
}
524
525
// RunHTTPRoutes serves path-routed HTTP upstreams through the exposure.
526
-func (e *Exposure) RunHTTPRoutes(ctx context.Context, routes []HTTPRoute, localAddr string) error {
526
+func (e *Exposure) RunHTTPRoutes(ctx context.Context, routes []HTTPRouteConfig, localAddr string) error {
527
cfg := e.Config()
528
- handler, err := newHTTPRouteHandler(routes, cfg.X402PayTo)
528
+ handler, err := NewHTTPRoutes(routes, cfg.X402PayTo)
529
if err != nil {
530
return err
531
}
sdk/http.go
+186
-346
@@ -1,25 +1,22 @@
1
package sdk
2
3
import (
4
- "bufio"
5
- "compress/gzip"
4
"context"
5
+ "encoding/json"
6
"errors"
7
"fmt"
9
- "io"
8
"net"
9
"net/http"
10
"net/http/httputil"
11
"net/url"
12
"sort"
15
- "strconv"
13
"strings"
14
"sync"
15
19
- "github.com/andybalholm/brotli"
16
"github.com/rs/zerolog/log"
17
18
"github.com/gosuda/portal-tunnel/v2/portal/x402"
19
+ "github.com/gosuda/portal-tunnel/v2/types"
20
"github.com/gosuda/portal-tunnel/v2/utils"
21
)
22
@@ -28,14 +25,14 @@ func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handl
25
return errors.New("relay listener or local address is required")
26
}
27
31
- serverHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32
- serveCompressedHTTP(handler, w, r)
33
- })
28
+ if handler == nil {
29
+ handler = http.NotFoundHandler()
30
+ }
31
32
var relaySrv *http.Server
33
if relayListener != nil {
34
relaySrv = &http.Server{
38
- Handler: serverHandler,
35
+ Handler: handler,
36
ReadHeaderTimeout: defaultRequestTimeout,
37
}
38
}
@@ -44,7 +41,7 @@ func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handl
41
if localAddr != "" {
42
localSrv = &http.Server{
43
Addr: localAddr,
47
- Handler: serverHandler,
44
+ Handler: handler,
45
ReadHeaderTimeout: defaultRequestTimeout,
46
}
47
}
@@ -126,28 +123,25 @@ func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handl
123
return errors.Join(serveErr, shutdownErr)
124
}
125
129
-// HTTPRoute maps one public path prefix to one local HTTP upstream.
130
-type HTTPRoute struct {
126
+// HTTPRouteConfig maps one public path prefix to one local HTTP upstream and optional x402 payment.
127
+type HTTPRouteConfig struct {
128
// Prefix is the public request path prefix, such as "/api" or "/".
129
Prefix string
130
// Upstream is the target HTTP URL, or a loopback host:port shorthand.
131
Upstream string
135
- // X402Price enables Sui USDC x402 payment for this public path prefix.
136
- X402Price string
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
+ Amount string
136
}
137
139
-type httpRoute struct {
140
- prefix string
141
- prefixSlash string
142
- upstream *url.URL
143
- upstreamPath string
144
- upstreamPathSlash string
145
- upstreamDomain string
146
- x402 *x402.Gate
147
- handler http.Handler
138
+// HTTPRoutes serves HTTPRouteConfig upstreams and the shared x402 prepare endpoint.
139
+type HTTPRoutes struct {
140
+ routes []*httpRoute
141
}
142
150
-func newHTTPRouteHandler(routeConfigs []HTTPRoute, x402PayTo string) (http.Handler, error) {
143
+// NewHTTPRoutes creates a handler for path-routed upstreams and the shared x402 prepare endpoint.
144
+func NewHTTPRoutes(routeConfigs []HTTPRouteConfig, x402PayTo string) (*HTTPRoutes, error) {
145
if len(routeConfigs) == 0 {
146
return nil, errors.New("at least one http route is required")
147
}
@@ -175,22 +169,73 @@ func newHTTPRouteHandler(routeConfigs []HTTPRoute, x402PayTo string) (http.Handl
169
return len(routes[i].prefix) > len(routes[j].prefix)
170
})
171
178
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
179
- p := r.URL.Path
180
- if p == "" {
181
- p = "/"
172
+ return &HTTPRoutes{routes: routes}, nil
173
+}
174
+
175
+func (h *HTTPRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) {
176
+ path := "/"
177
+ if r.URL != nil {
178
+ path = r.URL.Path
179
+ }
180
+ path = utils.NormalizeURLPath(path)
181
+ prepare := path == types.X402PreparePath
182
+ var paymentSender string
183
+ paymentMethod := http.MethodGet
184
+ if prepare {
185
+ if !utils.RequireMethod(w, r, http.MethodPost) {
186
+ return
187
+ }
188
+ var req types.X402PreparePaymentRequest
189
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
190
+ http.Error(w, "invalid payment prepare request", http.StatusBadRequest)
191
+ return
192
+ }
193
+ if strings.TrimSpace(req.Path) == "" {
194
+ http.Error(w, "path is required", http.StatusBadRequest)
195
+ return
196
+ }
197
+ path = utils.NormalizeURLPath(req.Path)
198
+ paymentSender = req.Sender
199
+ if method := strings.ToUpper(strings.TrimSpace(req.Method)); method != "" {
200
+ paymentMethod = method
201
+ }
202
+ }
203
+
204
+ for _, route := range h.routes {
205
+ if route.prefix != "/" && path != route.prefix && !strings.HasPrefix(path, route.prefix+"/") {
206
+ continue
207
}
183
- for _, route := range routes {
184
- if route.prefix == "/" || p == route.prefix || strings.HasPrefix(p, route.prefixSlash) {
185
- route.handler.ServeHTTP(w, r)
208
+
209
+ if prepare {
210
+ paid := route.payment != nil
211
+ if paid && len(route.paymentMethods) > 0 {
212
+ _, paid = route.paymentMethods[paymentMethod]
213
+ }
214
+ if !paid {
215
+ http.Error(w, "x402 payment is not enabled for path", http.StatusNotFound)
216
return
217
}
218
+ route.payment.WritePrepare(w, r, paymentSender, path)
219
+ return
220
}
189
- http.NotFound(w, r)
190
- }), nil
221
+
222
+ route.handler.ServeHTTP(w, r)
223
+ return
224
+ }
225
+ http.NotFound(w, r)
226
+}
227
+
228
+type httpRoute struct {
229
+ prefix string
230
+ upstream *url.URL
231
+ upstreamPath string
232
+ upstreamDomain string
233
+ payment *x402.Payment
234
+ paymentMethods map[string]struct{}
235
+ handler http.Handler
236
}
237
193
-func newHTTPRoute(routeConfig HTTPRoute, x402PayTo string) (*httpRoute, error) {
238
+func newHTTPRoute(routeConfig HTTPRouteConfig, x402PayTo string) (*httpRoute, error) {
239
prefix := strings.TrimSpace(routeConfig.Prefix)
240
if prefix == "" {
241
return nil, errors.New("http route prefix is required")
@@ -231,75 +276,107 @@ func newHTTPRoute(routeConfig HTTPRoute, x402PayTo string) (*httpRoute, error) {
276
upstreamPath: upstream.Path,
277
upstreamDomain: utils.NormalizeHostname(upstream.Hostname()),
278
}
234
- if prefix != "/" {
235
- route.prefixSlash = prefix + "/"
236
- }
237
- if upstream.Path != "/" {
238
- route.upstreamPathSlash = upstream.Path + "/"
279
+ amount := strings.TrimSpace(routeConfig.Amount)
280
+ if amount == "" && len(routeConfig.Methods) > 0 {
281
+ return nil, fmt.Errorf("http route %q payment methods require amount", route.prefix)
282
}
240
-
241
- x402Price := strings.TrimSpace(routeConfig.X402Price)
242
- if x402Price != "" {
283
+ if amount != "" {
284
if x402PayTo == "" {
244
- return nil, fmt.Errorf("http route %q x402 price requires x402 pay-to", route.prefix)
285
+ return nil, fmt.Errorf("http route %q amount requires x402 pay-to", route.prefix)
286
+ }
287
+ methods := make(map[string]struct{}, len(routeConfig.Methods))
288
+ for _, rawMethod := range routeConfig.Methods {
289
+ method := strings.ToUpper(strings.TrimSpace(rawMethod))
290
+ if method == "" {
291
+ return nil, fmt.Errorf("http route %q payment method is required", route.prefix)
292
+ }
293
+ methods[method] = struct{}{}
294
}
246
- gate, err := x402.NewUSDCGate(x402.GateConfig{
247
- Network: x402.MainnetNetwork,
248
- PayTo: x402PayTo,
249
- Amount: x402Price,
295
+ payment, err := x402.NewUSDCPayment(types.X402Payment{
296
+ PayTo: x402PayTo,
297
+ Amount: amount,
298
})
299
if err != nil {
300
return nil, fmt.Errorf("http route %q x402 payment: %w", route.prefix, err)
301
}
254
- route.x402 = gate
302
+ route.payment = payment
303
+ route.paymentMethods = methods
304
}
305
return route, nil
306
}
307
308
func (r *httpRoute) newHandler() http.Handler {
260
- proxy := r.newReverseProxy()
261
- if r.x402 == nil {
309
+ proxy := &httputil.ReverseProxy{
310
+ Rewrite: r.rewriteProxyRequest,
311
+ ModifyResponse: r.rewriteProxyResponse,
312
+ ErrorHandler: func(w http.ResponseWriter, req *http.Request, err error) {
313
+ log.Error().Err(err).
314
+ Str("route_prefix", r.prefix).
315
+ Str("upstream", r.upstream.String()).
316
+ Msg("http route proxy failed")
317
+ http.Error(w, "bad gateway", http.StatusBadGateway)
318
+ },
319
+ }
320
+ if r.payment == nil {
321
return proxy
322
}
323
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
265
- payment, err := r.x402.VerifyRequest(req.Context(), req)
266
- if err != nil {
267
- r.x402.WriteRequestError(w, req, err)
324
+ if len(r.paymentMethods) > 0 {
325
+ if _, ok := r.paymentMethods[strings.ToUpper(req.Method)]; !ok {
326
+ proxy.ServeHTTP(w, req)
327
+ return
328
+ }
329
+ }
330
+
331
+ paymentPayload, ok := r.payment.Verify(req.Context(), w, req)
332
+ if !ok {
333
+ return
334
+ }
335
+
336
+ settled, ok := r.payment.Settle(req.Context(), w, req, paymentPayload)
337
+ if !ok {
338
return
339
}
270
- proxy.ServeHTTP(w, req.WithContext(x402.ContextWithVerifiedPayment(req.Context(), payment)))
340
+ utils.SetPaymentResponseHeaders(w.Header(), settled)
341
+ proxy.ServeHTTP(w, req)
342
})
343
}
344
274
-func (r *httpRoute) newReverseProxy() *httputil.ReverseProxy {
275
- return &httputil.ReverseProxy{
276
- Rewrite: r.rewriteRequest,
277
- ModifyResponse: r.rewriteResponse,
278
- ErrorHandler: func(w http.ResponseWriter, req *http.Request, err error) {
279
- if errors.Is(err, x402.ErrSettlementFailed) {
280
- if r.x402 != nil {
281
- r.x402.WritePaymentRequired(w, req, "payment settlement failed")
282
- return
283
- }
284
- http.Error(w, "payment settlement failed", http.StatusPaymentRequired)
285
- return
345
+func (r *httpRoute) rewriteProxyRequest(pr *httputil.ProxyRequest) {
346
+ path := utils.NormalizeURLPath(pr.In.URL.Path)
347
+ rawPath := pr.In.URL.RawPath
348
+ if r.prefix != "/" {
349
+ switch {
350
+ case path == r.prefix:
351
+ path = "/"
352
+ default:
353
+ path = strings.TrimPrefix(path, r.prefix)
354
+ if path == "" {
355
+ path = "/"
356
}
287
- log.Error().Err(err).
288
- Str("route_prefix", r.prefix).
289
- Str("upstream", r.upstream.String()).
290
- Msg("http route proxy failed")
291
- http.Error(w, "bad gateway", http.StatusBadGateway)
292
- },
357
+ }
358
+
359
+ if rawPath != "" {
360
+ switch {
361
+ case rawPath == r.prefix:
362
+ rawPath = "/"
363
+ case strings.HasPrefix(rawPath, r.prefix+"/"):
364
+ rawPath = strings.TrimPrefix(rawPath, r.prefix)
365
+ }
366
+ }
367
}
294
-}
368
296
-func (r *httpRoute) rewriteRequest(pr *httputil.ProxyRequest) {
297
- pr.Out.URL.Path, pr.Out.URL.RawPath = r.publicRequestPathToUpstream(pr.In.URL.Path, pr.In.URL.RawPath)
369
+ pr.Out.URL.Path = path
370
+ pr.Out.URL.RawPath = rawPath
371
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
372
pr.SetURL(r.upstream)
373
pr.SetXForwarded()
301
- if r.x402 != nil {
302
- x402.StripPaymentHeaders(pr.Out.Header)
374
+ paid := r.payment != nil
375
+ if paid && len(r.paymentMethods) > 0 {
376
+ _, paid = r.paymentMethods[strings.ToUpper(pr.In.Method)]
377
+ }
378
+ if paid {
379
+ utils.StripPaymentHeaders(pr.Out.Header)
380
}
381
382
// SetXForwarded checks pr.In.TLS, but behind a TLS-terminating proxy
@@ -316,17 +393,44 @@ func (r *httpRoute) rewriteRequest(pr *httputil.ProxyRequest) {
393
}
394
}
395
319
-func (r *httpRoute) rewriteResponse(resp *http.Response) error {
396
+func (r *httpRoute) rewriteProxyResponse(resp *http.Response) error {
397
if resp == nil || resp.Request == nil {
398
return nil
399
}
400
401
header := resp.Header
402
+ paid := r.payment != nil
403
+ if paid && len(r.paymentMethods) > 0 {
404
+ _, paid = r.paymentMethods[strings.ToUpper(resp.Request.Method)]
405
+ }
406
+ if paid {
407
+ utils.StripPaymentHeaders(header)
408
+ }
409
publicHost := resp.Request.Header.Get("X-Forwarded-Host")
410
publicScheme := resp.Request.Header.Get("X-Forwarded-Proto")
411
+ publicPath := func(raw string) string {
412
+ raw = utils.NormalizeURLPath(raw)
413
+ if r.prefix != "/" && (raw == r.prefix || strings.HasPrefix(raw, r.prefix+"/")) {
414
+ return raw
415
+ }
416
328
- if err := r.settleX402Response(resp); err != nil {
329
- return err
417
+ rest := raw
418
+ if r.upstreamPath != "/" {
419
+ switch {
420
+ case raw == r.upstreamPath:
421
+ rest = "/"
422
+ case strings.HasPrefix(raw, r.upstreamPath+"/"):
423
+ rest = strings.TrimPrefix(raw, r.upstreamPath)
424
+ }
425
+ }
426
+
427
+ if r.prefix == "/" {
428
+ return rest
429
+ }
430
+ if rest == "/" {
431
+ return r.prefix
432
+ }
433
+ return r.prefix + rest
434
}
435
436
location := header.Get("Location")
@@ -347,7 +451,7 @@ func (r *httpRoute) rewriteResponse(resp *http.Response) error {
451
}
452
453
if parsed != nil {
350
- mapped := r.upstreamPathToPublic(parsed.Path)
454
+ mapped := publicPath(parsed.Path)
455
if strings.HasPrefix(mapped, "/") && (len(mapped) == 1 || (mapped[1] != '/' && mapped[1] != '\\')) {
456
parsed.Path = mapped
457
parsed.RawPath = ""
@@ -378,7 +482,7 @@ func (r *httpRoute) rewriteResponse(resp *http.Response) error {
482
483
changed := false
484
if cookie.Path != "" {
381
- if rewritten := r.upstreamPathToPublic(cookie.Path); rewritten != cookie.Path {
485
+ if rewritten := publicPath(cookie.Path); rewritten != cookie.Path {
486
cookie.Path = rewritten
487
changed = true
488
}
@@ -400,267 +504,3 @@ func (r *httpRoute) rewriteResponse(resp *http.Response) error {
504
505
return nil
506
}
403
-
404
-func (r *httpRoute) settleX402Response(resp *http.Response) error {
405
- if r.x402 == nil || resp == nil || resp.Request == nil || resp.StatusCode >= http.StatusBadRequest {
406
- return nil
407
- }
408
- payment, ok := x402.VerifiedPaymentFromContext(resp.Request.Context())
409
- if !ok {
410
- return nil
411
- }
412
- settled, err := r.x402.SettleVerifiedPayment(resp.Request.Context(), payment)
413
- if err != nil {
414
- return x402.SettlementError(err)
415
- }
416
- x402.SetPaymentResponseHeaders(resp.Header, settled)
417
- return nil
418
-}
419
-
420
-func (r *httpRoute) publicRequestPathToUpstream(path, rawPath string) (string, string) {
421
- path = utils.NormalizeURLPath(path)
422
- if r.prefix == "/" {
423
- return path, rawPath
424
- }
425
- if path == r.prefix {
426
- return "/", ""
427
- }
428
- path = strings.TrimPrefix(path, r.prefix)
429
- if path == "" {
430
- path = "/"
431
- }
432
-
433
- if rawPath != "" {
434
- switch {
435
- case rawPath == r.prefix:
436
- rawPath = "/"
437
- case strings.HasPrefix(rawPath, r.prefixSlash):
438
- rawPath = strings.TrimPrefix(rawPath, r.prefix)
439
- }
440
- }
441
- return path, rawPath
442
-}
443
-
444
-func (r *httpRoute) upstreamPathToPublic(raw string) string {
445
- raw = utils.NormalizeURLPath(raw)
446
- if r.prefix != "/" && (raw == r.prefix || strings.HasPrefix(raw, r.prefixSlash)) {
447
- return raw
448
- }
449
-
450
- rest := raw
451
- if r.upstreamPath != "/" {
452
- if raw == r.upstreamPath {
453
- rest = "/"
454
- } else if strings.HasPrefix(raw, r.upstreamPathSlash) {
455
- rest = strings.TrimPrefix(raw, r.upstreamPath)
456
- }
457
- }
458
-
459
- if r.prefix == "/" {
460
- return rest
461
- }
462
- if rest == "/" {
463
- return r.prefix
464
- }
465
- return r.prefix + rest
466
-}
467
-
468
-func serveCompressedHTTP(handler http.Handler, w http.ResponseWriter, r *http.Request) {
469
- if handler == nil {
470
- http.NotFound(w, r)
471
- return
472
- }
473
-
474
- format := ""
475
- parseQuality := func(params string) float64 {
476
- for param := range strings.SplitSeq(params, ";") {
477
- key, value, ok := strings.Cut(strings.TrimSpace(param), "=")
478
- if !ok || !strings.EqualFold(strings.TrimSpace(key), "q") {
479
- continue
480
- }
481
-
482
- q, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
483
- if err != nil || q < 0 {
484
- return 0
485
- }
486
- if q > 1 {
487
- return 1
488
- }
489
- return q
490
- }
491
- return 1
492
- }
493
-
494
- bestQ := 0.0
495
- for rawPart := range strings.SplitSeq(r.Header.Get("Accept-Encoding"), ",") {
496
- part := strings.TrimSpace(strings.ToLower(rawPart))
497
- if part == "" {
498
- continue
499
- }
500
-
501
- name, params, _ := strings.Cut(part, ";")
502
- candidate := strings.TrimSpace(name)
503
- if candidate != "br" && candidate != "gzip" {
504
- continue
505
- }
506
-
507
- q := parseQuality(params)
508
- if q <= 0 {
509
- continue
510
- }
511
-
512
- if q > bestQ || (q == bestQ && candidate == "br") {
513
- format = candidate
514
- bestQ = q
515
- }
516
- }
517
- if format == "" || strings.TrimSpace(r.Header.Get("Range")) != "" {
518
- handler.ServeHTTP(w, r)
519
- return
520
- }
521
- if headerContainsToken(r.Header.Values("Connection"), "upgrade") && strings.TrimSpace(r.Header.Get("Upgrade")) != "" {
522
- handler.ServeHTTP(w, r)
523
- return
524
- }
525
-
526
- writer := &compressedResponseWriter{
527
- ResponseWriter: w,
528
- format: format,
529
- }
530
- defer func() {
531
- _ = writer.Close()
532
- }()
533
-
534
- handler.ServeHTTP(writer, r)
535
-}
536
-
537
-func headerContainsToken(values []string, target string) bool {
538
- target = strings.ToLower(strings.TrimSpace(target))
539
- for _, value := range values {
540
- for _, part := range strings.Split(value, ",") {
541
- if strings.ToLower(strings.TrimSpace(part)) == target {
542
- return true
543
- }
544
- }
545
- }
546
- return false
547
-}
548
-
549
-type compressedResponseWriter struct {
550
- http.ResponseWriter
551
- format string
552
- writer io.WriteCloser
553
- flushWriter func() error
554
- wroteHeader bool
555
- passthrough bool
556
-}
557
-
558
-func (w *compressedResponseWriter) WriteHeader(statusCode int) {
559
- if w.wroteHeader {
560
- return
561
- }
562
- w.wroteHeader = true
563
-
564
- header := w.Header()
565
- contentType, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(header.Get("Content-Type"))), ";")
566
- contentType = strings.TrimSpace(contentType)
567
- compressible := strings.HasPrefix(contentType, "text/")
568
- switch contentType {
569
- case "application/json", "application/javascript", "application/xml", "image/svg+xml":
570
- compressible = true
571
- }
572
- smallResponse := false
573
- if contentLength := strings.TrimSpace(header.Get("Content-Length")); contentLength != "" {
574
- if n, err := strconv.ParseInt(contentLength, 10, 64); err == nil && n >= 0 && n <= 1024 {
575
- smallResponse = true
576
- }
577
- }
578
- switch {
579
- case statusCode >= 100 && statusCode < 200:
580
- w.passthrough = true
581
- case statusCode == http.StatusNoContent || statusCode == http.StatusNotModified:
582
- w.passthrough = true
583
- case !compressible:
584
- w.passthrough = true
585
- case smallResponse:
586
- w.passthrough = true
587
- case strings.TrimSpace(header.Get("Content-Encoding")) != "":
588
- w.passthrough = true
589
- case strings.TrimSpace(header.Get("Content-Range")) != "":
590
- w.passthrough = true
591
- case strings.HasPrefix(contentType, "text/event-stream"):
592
- w.passthrough = true
593
- case headerContainsToken(header.Values("Cache-Control"), "no-transform"):
594
- w.passthrough = true
595
- }
596
- if w.passthrough {
597
- w.ResponseWriter.WriteHeader(statusCode)
598
- return
599
- }
600
-
601
- switch w.format {
602
- case "br":
603
- writer := brotli.NewWriter(w.ResponseWriter)
604
- w.writer = writer
605
- w.flushWriter = writer.Flush
606
- case "gzip":
607
- writer := gzip.NewWriter(w.ResponseWriter)
608
- w.writer = writer
609
- w.flushWriter = writer.Flush
610
- default:
611
- w.passthrough = true
612
- w.ResponseWriter.WriteHeader(statusCode)
613
- return
614
- }
615
-
616
- header.Del("Content-Length")
617
- header.Set("Content-Encoding", w.format)
618
- if !headerContainsToken(header.Values("Vary"), "accept-encoding") {
619
- header.Add("Vary", "Accept-Encoding")
620
- }
621
- w.ResponseWriter.WriteHeader(statusCode)
622
-}
623
-
624
-func (w *compressedResponseWriter) Write(p []byte) (int, error) {
625
- if !w.wroteHeader {
626
- header := w.Header()
627
- if strings.TrimSpace(header.Get("Content-Type")) == "" && len(p) > 0 {
628
- header.Set("Content-Type", http.DetectContentType(p))
629
- }
630
- w.WriteHeader(http.StatusOK)
631
- }
632
- if w.passthrough {
633
- return w.ResponseWriter.Write(p)
634
- }
635
- return w.writer.Write(p)
636
-}
637
-
638
-func (w *compressedResponseWriter) Flush() {
639
- if !w.wroteHeader {
640
- w.WriteHeader(http.StatusOK)
641
- }
642
- if !w.passthrough && w.flushWriter != nil {
643
- _ = w.flushWriter()
644
- }
645
- if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
646
- flusher.Flush()
647
- }
648
-}
649
-
650
-func (w *compressedResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
651
- hijacker, ok := w.ResponseWriter.(http.Hijacker)
652
- if !ok {
653
- return nil, nil, http.ErrNotSupported
654
- }
655
- return hijacker.Hijack()
656
-}
657
-
658
-func (w *compressedResponseWriter) Close() error {
659
- if w.writer == nil {
660
- return nil
661
- }
662
- err := w.writer.Close()
663
- w.writer = nil
664
- w.flushWriter = nil
665
- return err
666
-}
sdk/http_test.go
+6
-125
@@ -7,125 +7,6 @@ import (
7
"testing"
8
)
9
10
-func TestServeCompressedHTTPChoosesAcceptedEncoding(t *testing.T) {
11
- t.Parallel()
12
-
13
- tests := []struct {
14
- name string
15
- acceptEncoding string
16
- want string
17
- }{
18
- {name: "missing header", want: ""},
19
- {name: "unsupported encoding only", acceptEncoding: "deflate", want: ""},
20
- {name: "gzip accepted", acceptEncoding: "gzip", want: "gzip"},
21
- {name: "brotli preferred on tie", acceptEncoding: "gzip, br", want: "br"},
22
- {name: "quality chooses gzip", acceptEncoding: "gzip;q=1, br;q=0.5", want: "gzip"},
23
- {name: "zero quality disables format", acceptEncoding: "gzip;q=0, br;q=0", want: ""},
24
- {name: "wildcard ignored", acceptEncoding: "*", want: ""},
25
- }
26
-
27
- for _, tt := range tests {
28
- t.Run(tt.name, func(t *testing.T) {
29
- t.Parallel()
30
-
31
- req := httptest.NewRequest("GET", "/", nil)
32
- if tt.acceptEncoding != "" {
33
- req.Header.Set("Accept-Encoding", tt.acceptEncoding)
34
- }
35
- rec := httptest.NewRecorder()
36
-
37
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
39
- w.Header().Set("Content-Length", "2048")
40
- _, _ = w.Write([]byte("hello world"))
41
- })
42
-
43
- serveCompressedHTTP(handler, rec, req)
44
-
45
- if got := rec.Header().Get("Content-Encoding"); got != tt.want {
46
- t.Fatalf("Content-Encoding = %q, want %q", got, tt.want)
47
- }
48
- })
49
- }
50
-}
51
-
52
-func TestServeCompressedHTTPCompressesTextResponses(t *testing.T) {
53
- t.Parallel()
54
-
55
- req := httptest.NewRequest("GET", "/", nil)
56
- req.Header.Set("Accept-Encoding", "gzip")
57
- rec := httptest.NewRecorder()
58
-
59
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
61
- _, _ = w.Write([]byte("hello world"))
62
- })
63
-
64
- serveCompressedHTTP(handler, rec, req)
65
-
66
- if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
67
- t.Fatalf("Content-Encoding = %q, want gzip", got)
68
- }
69
-}
70
-
71
-func TestServeCompressedHTTPBypassesBinaryResponses(t *testing.T) {
72
- t.Parallel()
73
-
74
- req := httptest.NewRequest("GET", "/", nil)
75
- req.Header.Set("Accept-Encoding", "gzip")
76
- rec := httptest.NewRecorder()
77
-
78
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
79
- w.Header().Set("Content-Type", "image/png")
80
- _, _ = w.Write([]byte("not-really-a-png"))
81
- })
82
-
83
- serveCompressedHTTP(handler, rec, req)
84
-
85
- if got := rec.Header().Get("Content-Encoding"); got != "" {
86
- t.Fatalf("Content-Encoding = %q, want empty", got)
87
- }
88
-}
89
-
90
-func TestServeCompressedHTTPBypassesSmallResponsesWithContentLength(t *testing.T) {
91
- t.Parallel()
92
-
93
- req := httptest.NewRequest("GET", "/", nil)
94
- req.Header.Set("Accept-Encoding", "gzip")
95
- rec := httptest.NewRecorder()
96
-
97
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
98
- w.Header().Set("Content-Type", "application/json")
99
- w.Header().Set("Content-Length", "12")
100
- _, _ = w.Write([]byte(`{"ok":true}`))
101
- })
102
-
103
- serveCompressedHTTP(handler, rec, req)
104
-
105
- if got := rec.Header().Get("Content-Encoding"); got != "" {
106
- t.Fatalf("Content-Encoding = %q, want empty", got)
107
- }
108
-}
109
-
110
-func TestServeCompressedHTTPIgnoresSmallThresholdWithoutContentLength(t *testing.T) {
111
- t.Parallel()
112
-
113
- req := httptest.NewRequest("GET", "/", nil)
114
- req.Header.Set("Accept-Encoding", "gzip")
115
- rec := httptest.NewRecorder()
116
-
117
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118
- w.Header().Set("Content-Type", "application/json")
119
- _, _ = w.Write([]byte(`{"ok":true}`))
120
- })
121
-
122
- serveCompressedHTTP(handler, rec, req)
123
-
124
- if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
125
- t.Fatalf("Content-Encoding = %q, want gzip", got)
126
- }
127
-}
128
-
10
func TestHTTPRoutesUseLongestPrefix(t *testing.T) {
11
t.Parallel()
12
@@ -141,12 +22,12 @@ func TestHTTPRoutesUseLongestPrefix(t *testing.T) {
22
}))
23
defer rootServer.Close()
24
144
- handler, err := newHTTPRouteHandler([]HTTPRoute{
25
+ handler, err := NewHTTPRoutes([]HTTPRouteConfig{
26
{Prefix: "/", Upstream: rootServer.URL},
27
{Prefix: "/api", Upstream: apiServer.URL},
28
}, "")
29
if err != nil {
149
- t.Fatalf("newHTTPRouteHandler() error = %v", err)
30
+ t.Fatalf("NewHTTPRoutes() error = %v", err)
31
}
32
33
req := httptest.NewRequest(http.MethodGet, "https://public.example/api/users?active=true", nil)
@@ -173,11 +54,11 @@ func TestHTTPRoutesRewriteResponseHeaders(t *testing.T) {
54
defer upstreamServer.Close()
55
upstreamURL = upstreamServer.URL
56
176
- handler, err := newHTTPRouteHandler([]HTTPRoute{
57
+ handler, err := NewHTTPRoutes([]HTTPRouteConfig{
58
{Prefix: "/app", Upstream: upstreamURL + "/base"},
59
}, "")
60
if err != nil {
180
- t.Fatalf("newHTTPRouteHandler() error = %v", err)
61
+ t.Fatalf("NewHTTPRoutes() error = %v", err)
62
}
63
64
req := httptest.NewRequest(http.MethodGet, "http://public.example/app/dashboard", nil)
@@ -196,11 +77,11 @@ func TestHTTPRoutesRewriteResponseHeaders(t *testing.T) {
77
func TestHTTPRoutesRejectDuplicateNormalizedPrefixes(t *testing.T) {
78
t.Parallel()
79
199
- _, err := newHTTPRouteHandler([]HTTPRoute{
80
+ _, err := NewHTTPRoutes([]HTTPRouteConfig{
81
{Prefix: "/api", Upstream: "127.0.0.1:3001"},
82
{Prefix: "/api/", Upstream: "127.0.0.1:3002"},
83
}, "")
84
if err == nil {
204
- t.Fatal("newHTTPRouteHandler() error = nil, want duplicate prefix error")
85
+ t.Fatal("NewHTTPRoutes() error = nil, want duplicate prefix error")
86
}
87
}
types/api.go
-9
@@ -190,15 +190,6 @@ type DomainResponse struct {
190
X402 X402FacilitatorInfo `json:"x402"`
191
}
192
193
-type X402FacilitatorInfo struct {
194
- Enabled bool `json:"enabled"`
195
- URL string `json:"url,omitempty"`
196
- Network string `json:"network,omitempty"`
197
- NetworkName string `json:"network_name,omitempty"`
198
- SupportedURL string `json:"supported_url,omitempty"`
199
- PayTo string `json:"pay_to,omitempty"`
200
-}
201
-
193
type ENSStatus struct {
194
Enabled bool `json:"enabled"`
195
Verified bool `json:"verified"`
types/paths.go
+2
@@ -43,6 +43,8 @@ const (
43
)
44
45
const (
46
+ X402PreparePath = "/x402/prepare"
47
+
48
PathAgentPrefix = "/agent"
49
PathAgentStatus = PathAgentPrefix + "/status"
50
PathAgentShutdown = PathAgentPrefix + "/shutdown"
types/types.go
+10
-4
@@ -10,10 +10,16 @@ import (
10
)
11
12
const (
13
- HeaderAccessToken = "X-Portal-Access-Token"
14
- MarkerKeepalive = byte(0x00)
15
- MarkerRawStart = byte(0x01)
16
- MarkerTLSStart = byte(0x02)
13
+ HeaderAccessToken = "X-Portal-Access-Token"
14
+ HeaderXPayment = "X-PAYMENT"
15
+ HeaderPaymentSignature = "PAYMENT-SIGNATURE"
16
+ HeaderPaymentRequired = "PAYMENT-REQUIRED"
17
+ HeaderXPaymentRequired = "X-PAYMENT-REQUIRED"
18
+ HeaderPaymentResponse = "PAYMENT-RESPONSE"
19
+ HeaderXPaymentResponse = "X-PAYMENT-RESPONSE"
20
+ MarkerKeepalive = byte(0x00)
21
+ MarkerRawStart = byte(0x01)
22
+ MarkerTLSStart = byte(0x02)
23
)
24
25
var (
types/x402.go
new
+64
@@ -0,0 +1,64 @@
1
+package types
2
+
3
+import (
4
+ "net/http"
5
+ "time"
6
+
7
+ facilitatortypes "github.com/gosuda/x402-facilitator/types"
8
+)
9
+
10
+// X402FacilitatorInfo describes relay-level x402 facilitator settings exposed by the API.
11
+type X402FacilitatorInfo struct {
12
+ Enabled bool `json:"enabled"`
13
+ URL string `json:"url,omitempty"`
14
+ Network string `json:"network,omitempty"`
15
+ NetworkName string `json:"network_name,omitempty"`
16
+ SupportedURL string `json:"supported_url,omitempty"`
17
+ PayTo string `json:"pay_to,omitempty"`
18
+}
19
+
20
+// X402Payment is the stable x402 payment contract shared by SDK helpers and payment apps.
21
+type X402Payment struct {
22
+ Testnet bool
23
+ Network string
24
+ NetworkName string
25
+ Asset string
26
+ PayTo string
27
+ Amount string
28
+ MaxTimeoutSeconds int
29
+ RequestTimeout time.Duration
30
+ Endpoints []string
31
+ ResourcePath string
32
+ ResourceDescription string
33
+ ResourceMimeType string
34
+}
35
+
36
+// X402PaymentResult is the successful settlement data passed to protected handlers.
37
+type X402PaymentResult struct {
38
+ TransactionID string
39
+ Network string
40
+ Payer string
41
+}
42
+
43
+// X402PaymentHandlerFunc handles a request after its x402 payment has settled.
44
+type X402PaymentHandlerFunc func(http.ResponseWriter, *http.Request, X402PaymentResult)
45
+
46
+// X402PreparePaymentRequest is the shared prepare endpoint request body.
47
+type X402PreparePaymentRequest struct {
48
+ Sender string `json:"sender"`
49
+ Method string `json:"method,omitempty"`
50
+ Path string `json:"path,omitempty"`
51
+}
52
+
53
+// X402PreparePaymentResponse is the wallet transaction payload returned by a payment prepare endpoint.
54
+type X402PreparePaymentResponse struct {
55
+ X402Version int `json:"x402Version"`
56
+ PaymentRequirements facilitatortypes.PaymentRequirements `json:"paymentRequirements"`
57
+ Resource *facilitatortypes.ResourceInfo `json:"resource,omitempty"`
58
+ PrepareTransaction *struct {
59
+ Transaction string `json:"transaction"`
60
+ } `json:"prepareTransaction,omitempty"`
61
+ PaymentTransaction struct {
62
+ Transaction string `json:"transaction"`
63
+ } `json:"paymentTransaction"`
64
+}
utils/api.go
+59
@@ -3,6 +3,7 @@ package utils
3
import (
4
"bytes"
5
"context"
6
+ "encoding/base64"
7
"encoding/json"
8
"errors"
9
"fmt"
@@ -12,6 +13,7 @@ import (
13
"strings"
14
15
"github.com/gosuda/portal-tunnel/v2/types"
16
+ facilitatortypes "github.com/gosuda/x402-facilitator/types"
17
)
18
19
type APIErrorResponse struct {
@@ -39,6 +41,35 @@ func WriteAPIError(w http.ResponseWriter, status int, code, message string) {
41
})
42
}
43
44
+func WritePaymentJSON(w http.ResponseWriter, status int, value any) {
45
+ w.Header().Set("Content-Type", "application/json")
46
+ w.Header().Set("Cache-Control", "no-store")
47
+ w.WriteHeader(status)
48
+ _ = json.NewEncoder(w).Encode(value)
49
+}
50
+
51
+func SetPaymentResponseHeaders(header http.Header, settled *facilitatortypes.PaymentSettleResponse) {
52
+ if header == nil || settled == nil {
53
+ return
54
+ }
55
+ raw, err := json.Marshal(settled)
56
+ if err != nil {
57
+ return
58
+ }
59
+ encoded := base64.StdEncoding.EncodeToString(raw)
60
+ header.Set(types.HeaderPaymentResponse, encoded)
61
+ header.Set(types.HeaderXPaymentResponse, encoded)
62
+}
63
+
64
+func StripPaymentHeaders(header http.Header) {
65
+ header.Del(types.HeaderXPayment)
66
+ header.Del(types.HeaderPaymentSignature)
67
+ header.Del(types.HeaderPaymentRequired)
68
+ header.Del(types.HeaderXPaymentRequired)
69
+ header.Del(types.HeaderPaymentResponse)
70
+ header.Del(types.HeaderXPaymentResponse)
71
+}
72
+
73
func HandleAPICORS(w http.ResponseWriter, r *http.Request) bool {
74
header := w.Header()
75
header.Set("Access-Control-Allow-Origin", "*")
@@ -76,6 +107,34 @@ func RequireMethod(w http.ResponseWriter, r *http.Request, method string) bool {
107
return false
108
}
109
110
+// PublicURLForPath resolves a public absolute URL from request forwarding headers.
111
+func PublicURLForPath(r *http.Request, path string) string {
112
+ if r == nil {
113
+ return ""
114
+ }
115
+ scheme, _, _ := strings.Cut(r.Header.Get("X-Forwarded-Proto"), ",")
116
+ scheme = strings.ToLower(strings.TrimSpace(scheme))
117
+ if scheme == "" {
118
+ if r.TLS != nil {
119
+ scheme = "https"
120
+ } else {
121
+ scheme = "http"
122
+ }
123
+ }
124
+ host, _, _ := strings.Cut(r.Header.Get("X-Forwarded-Host"), ",")
125
+ host = strings.TrimSpace(host)
126
+ if host == "" {
127
+ host = strings.TrimSpace(r.Host)
128
+ }
129
+ if host == "" {
130
+ return path
131
+ }
132
+ if !strings.HasPrefix(path, "/") {
133
+ path = "/" + path
134
+ }
135
+ return scheme + "://" + host + path
136
+}
137
+
138
func ResolveAPIURL(baseURL *url.URL, path string) *url.URL {
139
ref := &url.URL{Path: path}
140
if baseURL == nil {