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 {