feat: add USDC payment support for x402 tunnel

Kim committed Jun 4, 2026 at 14:00 UTC ea971cc34e6c89ef2783fd88304220cb9dce772c
17 files changed +1758 -42
cmd/payment-app/handler.go new
+442
@@ -0,0 +1,442 @@
1 +package main
2 +
3 +import (
4 + "context"
5 + "embed"
6 + "encoding/base64"
7 + "encoding/json"
8 + "fmt"
9 + "html/template"
10 + "io/fs"
11 + "net/http"
12 + "strings"
13 + "time"
14 +
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 +
19 + "github.com/gosuda/portal-tunnel/v2/types"
20 + "github.com/gosuda/portal-tunnel/v2/utils"
21 +)
22 +
23 +//go:embed static/index.html static/style.css
24 +var staticFiles embed.FS
25 +
26 +const paidPhotoPath = "/paid/photo"
27 +
28 +var (
29 + indexPage = template.Must(template.ParseFS(staticFiles, "static/index.html"))
30 + photoPage = template.Must(template.New("photo").Parse(`<!DOCTYPE html>
31 +<html lang="en">
32 +<head>
33 + <meta charset="UTF-8">
34 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
35 + <title>{{.PageTitle}}</title>
36 + <meta name="description" content="{{.PageDescription}}">
37 + <meta property="og:type" content="website">
38 + <meta property="og:title" content="{{.PageTitle}}">
39 + <meta property="og:description" content="{{.PageDescription}}">
40 + <meta property="og:image" content="{{.OGImage}}">
41 + <meta property="og:url" content="{{.URL}}">
42 + <meta name="twitter:card" content="summary_large_image">
43 + <meta name="twitter:title" content="{{.PageTitle}}">
44 + <meta name="twitter:description" content="{{.PageDescription}}">
45 + <meta name="twitter:image" content="{{.OGImage}}">
46 + <style>
47 + * { box-sizing: border-box; }
48 + body {
49 + margin: 0;
50 + min-height: 100vh;
51 + display: flex;
52 + padding: 0;
53 + background: #f7f8fb;
54 + color: #182033;
55 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
56 + }
57 + main {
58 + display: grid;
59 + width: 100%;
60 + min-height: 100vh;
61 + grid-template-rows: minmax(0, 1fr) auto;
62 + overflow: hidden;
63 + background: #ffffff;
64 + }
65 + img {
66 + width: 100%;
67 + height: 100%;
68 + min-height: 0;
69 + display: block;
70 + object-fit: contain;
71 + background: #111827;
72 + }
73 + section {
74 + display: grid;
75 + gap: 10px;
76 + padding: 18px;
77 + border-top: 1px solid #e5eaf0;
78 + }
79 + .eyebrow {
80 + margin: 0;
81 + color: #0e7490;
82 + font-size: 12px;
83 + font-weight: 800;
84 + text-transform: uppercase;
85 + letter-spacing: 0;
86 + }
87 + h1 {
88 + margin: 0;
89 + color: #111827;
90 + font-size: 25px;
91 + line-height: 1.18;
92 + }
93 + p {
94 + margin: 0;
95 + color: #4b5565;
96 + font-size: 15px;
97 + line-height: 1.55;
98 + }
99 + dl {
100 + display: grid;
101 + grid-template-columns: repeat(3, 1fr);
102 + gap: 10px;
103 + margin: 4px 0 0;
104 + }
105 + div { min-width: 0; }
106 + dt {
107 + color: #667085;
108 + font-size: 12px;
109 + font-weight: 700;
110 + }
111 + dd {
112 + margin: 4px 0 0;
113 + overflow-wrap: anywhere;
114 + color: #111827;
115 + font-size: 13px;
116 + font-weight: 750;
117 + }
118 + @media (max-width: 640px) {
119 + section { padding: 14px; }
120 + dl { grid-template-columns: 1fr; }
121 + }
122 + </style>
123 +</head>
124 +<body>
125 + <main>
126 + <img src="{{.PhotoURL}}" alt="Unlocked protected image">
127 + <section>
128 + <p class="eyebrow">Payment complete</p>
129 + <h1>Image unlocked</h1>
130 + <p>The protected image is available after the {{.Amount}} atomic USDC x402 settlement.</p>
131 + <dl>
132 + <div>
133 + <dt>Amount</dt>
134 + <dd>{{.Amount}} atomic USDC</dd>
135 + </div>
136 + <div>
137 + <dt>Network</dt>
138 + <dd>{{.NetworkName}}</dd>
139 + </div>
140 + <div>
141 + <dt>Recipient</dt>
142 + <dd>{{.RecipientAddress}}</dd>
143 + </div>
144 + {{if .TransactionID}}
145 + <div>
146 + <dt>Transaction</dt>
147 + <dd>{{.TransactionID}}</dd>
148 + </div>
149 + {{end}}
150 + </dl>
151 + </section>
152 + </main>
153 +</body>
154 +</html>
155 +`))
156 +)
157 +
158 +type paymentHandlerConfig struct {
159 + Metadata types.LeaseMetadata
160 + Testnet bool
161 + PayTo string
162 + Amount string
163 + MaxTimeoutSeconds int
164 + RequestTimeout time.Duration
165 + Endpoints []string
166 + PhotoURL string
167 +}
168 +
169 +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
177 +}
178 +
179 +type paymentPageData struct {
180 + PageTitle string
181 + PageDescription string
182 + URL string
183 + OGImage string
184 + ProtectedPath string
185 + Network string
186 + NetworkName string
187 + Asset string
188 + Amount string
189 + PhotoURL string
190 + RecipientAddress string
191 + TransactionID string
192 + ConfigJSON template.JS
193 +}
194 +
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 +
211 +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 + })
218 + if err != nil {
219 + return nil, err
220 + }
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 + }
235 +
236 + staticFS, err := fs.Sub(staticFiles, "static")
237 + if err != nil {
238 + return nil, err
239 + }
240 + mux := http.NewServeMux()
241 + mux.Handle("/static/style.css", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
242 + mux.HandleFunc("/api/payment/prepare", handler.handlePreparePayment)
243 + mux.HandleFunc("/", handler.handleIndex)
244 + mux.HandleFunc(paidPhotoPath, handler.handlePaidPhoto)
245 + return mux, nil
246 +}
247 +
248 +func (h *paymentHandler) handleIndex(w http.ResponseWriter, r *http.Request) {
249 + if r.URL.Path != "/" {
250 + http.NotFound(w, r)
251 + return
252 + }
253 + if !utils.RequireMethod(w, r, http.MethodGet) {
254 + return
255 + }
256 + data := h.newPaymentPageData(r)
257 + w.Header().Set("Content-Type", "text/html; charset=utf-8")
258 + w.Header().Set("Cache-Control", "no-store")
259 + _ = indexPage.Execute(w, data)
260 +}
261 +
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 +
361 + data := h.newPaymentPageData(r)
362 + data.URL = publicURLForPath(r, paidPhotoPath)
363 + data.TransactionID = strings.TrimSpace(settled.Transaction)
364 + w.Header().Set("Content-Type", "text/html; charset=utf-8")
365 + w.Header().Set("Cache-Control", "no-store")
366 + _ = photoPage.Execute(w, data)
367 +}
368 +
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 +
376 +func (h *paymentHandler) newPaymentPageData(r *http.Request) paymentPageData {
377 + requirements := h.requirements
378 + description := strings.TrimSpace(h.metadata.Description)
379 + if description == "" {
380 + description = "Connect a Sui wallet, settle USDC with x402, and reveal the protected image."
381 + }
382 + config := map[string]string{
383 + "network": requirements.Network,
384 + "networkName": h.networkName,
385 + "asset": requirements.Asset,
386 + "amount": requirements.Amount,
387 + "payTo": requirements.PayTo,
388 + "protectedPath": paidPhotoPath,
389 + }
390 + configJSON, err := json.Marshal(config)
391 + if err != nil {
392 + configJSON = []byte("{}")
393 + }
394 + return paymentPageData{
395 + PageTitle: "Portal Sui Wallet Payment",
396 + PageDescription: description,
397 + URL: publicURLForPath(r, "/"),
398 + OGImage: h.photoURL,
399 + ProtectedPath: paidPhotoPath,
400 + Network: requirements.Network,
401 + NetworkName: h.networkName,
402 + Asset: requirements.Asset,
403 + Amount: requirements.Amount,
404 + PhotoURL: h.photoURL,
405 + RecipientAddress: requirements.PayTo,
406 + ConfigJSON: template.JS(string(configJSON)),
407 + }
408 +}
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 new
+194
@@ -0,0 +1,194 @@
1 +package main
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "flag"
7 + "fmt"
8 + "io"
9 + "os"
10 + "strings"
11 + "time"
12 +
13 + "github.com/rs/zerolog"
14 + "github.com/rs/zerolog/log"
15 +
16 + "github.com/gosuda/portal-tunnel/v2/sdk"
17 + "github.com/gosuda/portal-tunnel/v2/types"
18 + "github.com/gosuda/portal-tunnel/v2/utils"
19 +)
20 +
21 +const (
22 + defaultThumbnailURL = "https://image.s-h.day/generated/1e56ad0f0a1d.png"
23 + defaultPhotoURL = "https://image.s-h.day/generated/905a4835ad50.png"
24 +)
25 +
26 +type paymentConfig struct {
27 + relayURLs string
28 + discovery bool
29 + banMITM bool
30 + identityPath string
31 + identityJSON string
32 + addr string
33 + name string
34 + desc string
35 + tags string
36 + owner string
37 + hide bool
38 + thumbnail string
39 + photoURL string
40 + maxActiveRelays int
41 +
42 + x402Testnet bool
43 + x402PayTo string
44 + x402Amount string
45 + x402RPCs []string
46 + x402MaxTimeoutSeconds int
47 + x402RequestTimeout int
48 +}
49 +
50 +func main() {
51 + log.Logger = log.Output(zerolog.NewConsoleWriter())
52 + if err := run(os.Args[1:]); err != nil {
53 + log.Error().Err(err).Msg("payment app failed")
54 + os.Exit(1)
55 + }
56 +}
57 +
58 +func run(args []string) error {
59 + cfg := paymentConfig{}
60 + fs := utils.NewFlagSet("payment-app", printUsage)
61 +
62 + utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://localhost", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with bootstrap relays when discovery is enabled)", "RELAYS")
63 + utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", false, "include bootstrap relays and enable discovery", "DISCOVERY")
64 + utils.BoolFlagEnv(fs, &cfg.banMITM, "ban-mitm", false, "ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
65 + utils.StringFlagEnv(fs, &cfg.identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH")
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)")
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")
73 + utils.StringFlag(fs, &cfg.thumbnail, "thumbnail", defaultThumbnailURL, "lease thumbnail")
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")
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")
81 + fs.IntVar(&cfg.x402RequestTimeout, "x402-request-timeout", 30, "Sui RPC and x402 verify/settle timeout seconds")
82 +
83 + if err := utils.ParseFlagSet(fs, args, printUsage); err != nil {
84 + if errors.Is(err, flag.ErrHelp) {
85 + return nil
86 + }
87 + return err
88 + }
89 + if err := utils.RequireNoArgs(fs.Args(), "payment-app"); err != nil {
90 + printUsage(os.Stderr)
91 + return err
92 + }
93 + normalizedName, err := utils.NormalizeDNSLabel(cfg.name)
94 + if err != nil {
95 + return fmt.Errorf("invalid --name value: %w", err)
96 + }
97 + cfg.name = normalizedName
98 + if err := validatePaymentConfig(cfg); err != nil {
99 + return err
100 + }
101 +
102 + ctx, stop := utils.SignalContext()
103 + defer stop()
104 +
105 + return runPaymentApp(ctx, cfg)
106 +}
107 +
108 +func validatePaymentConfig(cfg paymentConfig) error {
109 + switch {
110 + case strings.TrimSpace(cfg.x402PayTo) == "":
111 + return errors.New("--x402-pay-to is required")
112 + case strings.TrimSpace(cfg.x402Amount) == "":
113 + return errors.New("--x402-amount is required")
114 + case strings.TrimSpace(cfg.photoURL) == "":
115 + return errors.New("--photo-url is required")
116 + case cfg.x402MaxTimeoutSeconds < 0:
117 + return errors.New("--x402-max-timeout cannot be negative")
118 + case cfg.x402RequestTimeout < 0:
119 + return errors.New("--x402-request-timeout cannot be negative")
120 + default:
121 + return nil
122 + }
123 +}
124 +
125 +func runPaymentApp(ctx context.Context, cfg paymentConfig) error {
126 + metadata := types.LeaseMetadata{
127 + Description: cfg.desc,
128 + Tags: utils.SplitCSV(cfg.tags),
129 + Owner: cfg.owner,
130 + Thumbnail: cfg.thumbnail,
131 + Hide: cfg.hide,
132 + }
133 + rawAddr := cfg.addr
134 + addr, err := utils.NormalizeTargetAddr(cfg.addr)
135 + if err != nil {
136 + return fmt.Errorf("invalid --addr value %q: %w", rawAddr, err)
137 + }
138 +
139 + handler, err := newHandler(paymentHandlerConfig{
140 + Metadata: metadata,
141 + Testnet: cfg.x402Testnet,
142 + PayTo: cfg.x402PayTo,
143 + Amount: cfg.x402Amount,
144 + MaxTimeoutSeconds: cfg.x402MaxTimeoutSeconds,
145 + RequestTimeout: time.Duration(cfg.x402RequestTimeout) * time.Second,
146 + Endpoints: cfg.x402RPCs,
147 + PhotoURL: cfg.photoURL,
148 + })
149 + if err != nil {
150 + return err
151 + }
152 +
153 + exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
154 + RelayURLs: utils.SplitCSV(cfg.relayURLs),
155 + Discovery: cfg.discovery,
156 + Identity: types.Identity{Name: cfg.name},
157 + IdentityPath: cfg.identityPath,
158 + IdentityJSON: cfg.identityJSON,
159 + BanMITM: cfg.banMITM,
160 + MaxActiveRelays: cfg.maxActiveRelays,
161 + Metadata: metadata,
162 + })
163 + if err != nil {
164 + return fmt.Errorf("exposure listen error: %w", err)
165 + }
166 + defer exposure.Close()
167 +
168 + err = exposure.RunHTTP(ctx, handler, addr)
169 + if err != nil {
170 + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
171 + err = nil
172 + }
173 + return err
174 + }
175 +
176 + if ctx.Err() != nil {
177 + log.Info().Msg("payment app shutting down")
178 + }
179 + log.Info().Msg("payment app shutdown complete")
180 + return nil
181 +}
182 +
183 +func printUsage(w io.Writer) {
184 + utils.WriteCommandUsage(w,
185 + []string{
186 + "payment-app --x402-pay-to SUI_ADDRESS [flags]",
187 + },
188 + []string{
189 + "payment-app --x402-pay-to 0x...",
190 + "payment-app --name paid-photo --x402-pay-to 0x... --x402-amount 10000",
191 + "payment-app --x402-testnet=false --x402-pay-to 0x... --x402-amount 10000",
192 + },
193 + )
194 +}
cmd/payment-app/static/index.html new
+301
@@ -0,0 +1,301 @@
1 +<!DOCTYPE html>
2 +<html lang="en">
3 +
4 +<head>
5 + <meta charset="UTF-8">
6 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
7 + <title>{{.PageTitle}}</title>
8 + <meta name="description" content="{{.PageDescription}}">
9 + <meta property="og:type" content="website">
10 + <meta property="og:title" content="{{.PageTitle}}">
11 + <meta property="og:description" content="{{.PageDescription}}">
12 + <meta property="og:image" content="{{.OGImage}}">
13 + <meta property="og:url" content="{{.URL}}">
14 + <meta name="twitter:card" content="summary_large_image">
15 + <meta name="twitter:title" content="{{.PageTitle}}">
16 + <meta name="twitter:description" content="{{.PageDescription}}">
17 + <meta name="twitter:image" content="{{.OGImage}}">
18 + <link rel="stylesheet" href="/static/style.css">
19 + <script id="payment-config" type="application/json">{{.ConfigJSON}}</script>
20 +</head>
21 +
22 +<body>
23 + <main class="shell">
24 + <section class="app-surface" aria-label="Payment app">
25 + <div class="summary">
26 + <div>
27 + <div class="kicker">
28 + <span>Sui wallet</span>
29 + <span>{{.NetworkName}}</span>
30 + <span>USDC</span>
31 + </div>
32 + <h1>Paid image delivery</h1>
33 + <p class="lede">Connect a Sui wallet, sign the x402 payment transaction, and reveal the protected image in the viewer.</p>
34 +
35 + <dl class="facts">
36 + <div>
37 + <dt>Amount</dt>
38 + <dd>{{.Amount}} atomic USDC</dd>
39 + </div>
40 + <div>
41 + <dt>Network</dt>
42 + <dd>{{.Network}}</dd>
43 + </div>
44 + <div>
45 + <dt>Recipient</dt>
46 + <dd>{{.RecipientAddress}}</dd>
47 + </div>
48 + </dl>
49 + </div>
50 +
51 + <div class="checkout">
52 + <label class="wallet-label" for="walletSelect">Wallet</label>
53 + <select id="walletSelect" class="wallet-select"></select>
54 + <div class="actions">
55 + <button id="unlockButton" class="primary-button" type="button" disabled>Connect and pay</button>
56 + </div>
57 + <p id="status" class="status">Looking for Sui wallets</p>
58 + </div>
59 + </div>
60 +
61 + <div id="viewer" class="preview" aria-label="Protected image viewer">
62 + <div class="preview-topbar">
63 + <div class="window-controls" aria-hidden="true">
64 + <span></span>
65 + <span></span>
66 + <span></span>
67 + </div>
68 + <span id="viewerTitle" class="viewer-title">Protected image</span>
69 + <button id="resetViewer" class="viewer-reset" type="button" hidden>Reset</button>
70 + </div>
71 + <div id="lockedPreview" class="preview-body">
72 + <div class="lock-mark">402</div>
73 + <div>
74 + <p class="preview-label">Protected image</p>
75 + <p class="preview-title">Wallet payment opens here</p>
76 + </div>
77 + </div>
78 + <iframe id="paymentFrame" class="payment-frame" title="Unlocked protected image" hidden></iframe>
79 + </div>
80 + </section>
81 + </main>
82 +
83 + <script type="module">
84 + import { getWallets } from 'https://esm.sh/@wallet-standard/app';
85 + import { Transaction } from 'https://esm.sh/@mysten/sui/transactions';
86 +
87 + const config = JSON.parse(document.getElementById('payment-config').textContent || '{}');
88 + const walletsApi = getWallets();
89 + const walletSelect = document.getElementById('walletSelect');
90 + const unlockButton = document.getElementById('unlockButton');
91 + const statusEl = document.getElementById('status');
92 + const viewer = document.getElementById('viewer');
93 + const lockedPreview = document.getElementById('lockedPreview');
94 + const paymentFrame = document.getElementById('paymentFrame');
95 + const resetViewer = document.getElementById('resetViewer');
96 + const viewerTitle = document.getElementById('viewerTitle');
97 +
98 + function setStatus(value) {
99 + statusEl.textContent = value;
100 + }
101 +
102 + function supportsPayment(candidate) {
103 + const features = candidate?.features || {};
104 + return Boolean(
105 + features['standard:connect'] &&
106 + (features['sui:signTransaction'] || features['sui:signTransactionBlock'])
107 + );
108 + }
109 +
110 + function currentWallets() {
111 + return walletsApi.get().filter(supportsPayment);
112 + }
113 +
114 + function refreshWallets() {
115 + const wallets = currentWallets();
116 + walletSelect.replaceChildren(...wallets.map((candidate, index) => {
117 + const option = document.createElement('option');
118 + option.value = String(index);
119 + option.textContent = candidate.name || `Wallet ${index + 1}`;
120 + return option;
121 + }));
122 + const hasWallets = wallets.length > 0;
123 + walletSelect.disabled = !hasWallets;
124 + unlockButton.disabled = !hasWallets;
125 + unlockButton.textContent = 'Connect and pay';
126 + setStatus(hasWallets ? 'Select a wallet and continue' : 'Install a Sui wallet extension');
127 + }
128 +
129 + function pickAccount(accounts) {
130 + return accounts.find((candidate) => Array.isArray(candidate.chains) && candidate.chains.includes(config.network)) || accounts[0] || null;
131 + }
132 +
133 + async function connectWallet() {
134 + const wallet = currentWallets()[Number(walletSelect.value)];
135 + if (!wallet) {
136 + throw new Error('No Sui wallet selected');
137 + }
138 + const response = await wallet.features['standard:connect'].connect();
139 + const connectedAccount = pickAccount(response.accounts || wallet.accounts || []);
140 + if (!connectedAccount) {
141 + throw new Error('Connected wallet did not return an account');
142 + }
143 + if (Array.isArray(connectedAccount.chains) && !connectedAccount.chains.includes(config.network)) {
144 + throw new Error(`Connected account does not advertise ${config.network}`);
145 + }
146 + return { wallet, account: connectedAccount };
147 + }
148 +
149 + function base64ToBytes(value) {
150 + const binary = atob(value);
151 + const bytes = new Uint8Array(binary.length);
152 + for (let i = 0; i < binary.length; i += 1) {
153 + bytes[i] = binary.charCodeAt(i);
154 + }
155 + return bytes;
156 + }
157 +
158 + function bytesToBase64(bytes) {
159 + let binary = '';
160 + for (let i = 0; i < bytes.length; i += 0x8000) {
161 + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
162 + }
163 + return btoa(binary);
164 + }
165 +
166 + function transactionFromBase64(value) {
167 + return Transaction.from(base64ToBytes(value));
168 + }
169 +
170 + async function signTransaction(wallet, account, transactionBytes) {
171 + const tx = transactionFromBase64(transactionBytes);
172 + if (wallet.features['sui:signTransaction']) {
173 + return wallet.features['sui:signTransaction'].signTransaction({
174 + transaction: tx,
175 + account,
176 + chain: config.network,
177 + });
178 + }
179 + return wallet.features['sui:signTransactionBlock'].signTransactionBlock({
180 + transactionBlock: tx,
181 + account,
182 + chain: config.network,
183 + });
184 + }
185 +
186 + async function executePrepareTransaction(wallet, account, transactionBytes) {
187 + const tx = transactionFromBase64(transactionBytes);
188 + if (wallet.features['sui:signAndExecuteTransaction']) {
189 + return wallet.features['sui:signAndExecuteTransaction'].signAndExecuteTransaction({
190 + transaction: tx,
191 + account,
192 + chain: config.network,
193 + });
194 + }
195 + if (wallet.features['sui:signAndExecuteTransactionBlock']) {
196 + return wallet.features['sui:signAndExecuteTransactionBlock'].signAndExecuteTransactionBlock({
197 + transactionBlock: tx,
198 + account,
199 + chain: config.network,
200 + });
201 + }
202 + throw new Error('This wallet cannot execute the USDC prepare transaction');
203 + }
204 +
205 + function encodePaymentPayload(payload) {
206 + return bytesToBase64(new TextEncoder().encode(JSON.stringify(payload)));
207 + }
208 +
209 + async function preparePayment(sender) {
210 + const response = await fetch('/api/payment/prepare', {
211 + method: 'POST',
212 + headers: { 'Content-Type': 'application/json' },
213 + body: JSON.stringify({ sender }),
214 + });
215 + if (!response.ok) {
216 + throw new Error(await response.text());
217 + }
218 + return response.json();
219 + }
220 +
221 + async function unlock() {
222 + unlockButton.disabled = true;
223 + walletSelect.disabled = true;
224 + paymentFrame.removeAttribute('srcdoc');
225 + try {
226 + setStatus('Connecting wallet');
227 + const { wallet, account } = await connectWallet();
228 +
229 + viewer.classList.add('is-active');
230 + resetViewer.hidden = false;
231 + lockedPreview.hidden = true;
232 + paymentFrame.hidden = false;
233 + viewerTitle.textContent = 'Sui wallet payment';
234 +
235 + setStatus('Preparing USDC transaction');
236 + const prepared = await preparePayment(account.address);
237 + if (prepared.prepareTransaction) {
238 + setStatus('Preparing object balance in wallet');
239 + await executePrepareTransaction(wallet, account, prepared.prepareTransaction.transaction);
240 + }
241 +
242 + setStatus('Signing x402 payment');
243 + const signed = await signTransaction(wallet, account, prepared.paymentTransaction.transaction);
244 + const paymentPayload = {
245 + x402Version: prepared.x402Version,
246 + payload: {
247 + signature: signed.signature,
248 + transaction: signed.bytes || signed.transactionBlockBytes || prepared.paymentTransaction.transaction,
249 + },
250 + accepted: prepared.paymentRequirements,
251 + resource: prepared.resource,
252 + };
253 +
254 + setStatus('Settling payment');
255 + const protectedResponse = await fetch(config.protectedPath, {
256 + method: 'GET',
257 + headers: {
258 + 'X-PAYMENT': encodePaymentPayload(paymentPayload),
259 + },
260 + });
261 + if (!protectedResponse.ok) {
262 + throw new Error(await protectedResponse.text());
263 + }
264 + paymentFrame.srcdoc = await protectedResponse.text();
265 + viewerTitle.textContent = 'Image unlocked';
266 + setStatus('Payment complete');
267 + } catch (error) {
268 + lockedPreview.hidden = false;
269 + paymentFrame.hidden = true;
270 + paymentFrame.removeAttribute('srcdoc');
271 + viewerTitle.textContent = 'Protected image';
272 + setStatus(error instanceof Error ? error.message.trim() : String(error));
273 + } finally {
274 + const hasWallets = currentWallets().length > 0;
275 + walletSelect.disabled = !hasWallets;
276 + unlockButton.disabled = !hasWallets;
277 + unlockButton.textContent = 'Connect and pay';
278 + }
279 + }
280 +
281 + unlockButton.addEventListener('click', unlock);
282 +
283 + resetViewer.addEventListener('click', () => {
284 + viewer.classList.remove('is-active');
285 + lockedPreview.hidden = false;
286 + paymentFrame.hidden = true;
287 + paymentFrame.removeAttribute('srcdoc');
288 + resetViewer.hidden = true;
289 + viewerTitle.textContent = 'Protected image';
290 + setStatus('Ready');
291 + });
292 +
293 + refreshWallets();
294 + if (typeof walletsApi.on === 'function') {
295 + walletsApi.on('register', refreshWallets);
296 + walletsApi.on('unregister', refreshWallets);
297 + }
298 + </script>
299 +</body>
300 +
301 +</html>
cmd/payment-app/static/style.css new
+379
@@ -0,0 +1,379 @@
1 +* {
2 + box-sizing: border-box;
3 +}
4 +
5 +body {
6 + margin: 0;
7 + min-height: 100vh;
8 + background: #f4f7f8;
9 + color: #182033;
10 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
11 +}
12 +
13 +button,
14 +select {
15 + font: inherit;
16 +}
17 +
18 +.shell {
19 + width: min(1120px, calc(100% - 32px));
20 + margin: 0 auto;
21 + padding: 42px 0;
22 +}
23 +
24 +.app-surface {
25 + display: grid;
26 + grid-template-columns: minmax(0, 0.88fr) minmax(360px, 1.12fr);
27 + gap: 24px;
28 + align-items: stretch;
29 +}
30 +
31 +.summary,
32 +.preview {
33 + border: 1px solid #d8e0e7;
34 + border-radius: 8px;
35 + background: #ffffff;
36 + box-shadow: 0 18px 38px rgba(20, 32, 46, 0.10);
37 +}
38 +
39 +.summary {
40 + display: flex;
41 + min-height: 500px;
42 + flex-direction: column;
43 + justify-content: space-between;
44 + padding: 28px;
45 +}
46 +
47 +.kicker {
48 + display: flex;
49 + flex-wrap: wrap;
50 + gap: 8px;
51 + margin-bottom: 24px;
52 +}
53 +
54 +.kicker span {
55 + border: 1px solid #c7d2dc;
56 + border-radius: 999px;
57 + padding: 5px 9px;
58 + color: #334155;
59 + font-size: 12px;
60 + font-weight: 750;
61 +}
62 +
63 +h1,
64 +p,
65 +dl {
66 + margin: 0;
67 +}
68 +
69 +h1 {
70 + max-width: 10ch;
71 + color: #121826;
72 + font-size: 48px;
73 + line-height: 1.02;
74 + font-weight: 800;
75 +}
76 +
77 +.lede {
78 + max-width: 36ch;
79 + margin-top: 16px;
80 + color: #536171;
81 + font-size: 16px;
82 + line-height: 1.55;
83 +}
84 +
85 +.facts {
86 + display: grid;
87 + gap: 12px;
88 + margin-top: 28px;
89 +}
90 +
91 +.facts div {
92 + min-width: 0;
93 + border-top: 1px solid #e5ebf0;
94 + padding-top: 12px;
95 +}
96 +
97 +dt {
98 + color: #687586;
99 + font-size: 12px;
100 + font-weight: 750;
101 +}
102 +
103 +dd {
104 + margin: 4px 0 0;
105 + overflow-wrap: anywhere;
106 + color: #111827;
107 + font-size: 14px;
108 + font-weight: 760;
109 +}
110 +
111 +.checkout {
112 + display: grid;
113 + gap: 10px;
114 + margin-top: 30px;
115 +}
116 +
117 +.wallet-label {
118 + color: #475569;
119 + font-size: 12px;
120 + font-weight: 800;
121 +}
122 +
123 +.wallet-select {
124 + width: 100%;
125 + min-height: 42px;
126 + border: 1px solid #cbd5e1;
127 + border-radius: 6px;
128 + padding: 0 10px;
129 + background: #ffffff;
130 + color: #111827;
131 +}
132 +
133 +.actions {
134 + display: flex;
135 + flex-wrap: wrap;
136 + gap: 12px;
137 + align-items: center;
138 +}
139 +
140 +.primary-button {
141 + min-height: 42px;
142 + border-radius: 6px;
143 + padding: 0 16px;
144 + cursor: pointer;
145 + font-size: 14px;
146 + font-weight: 780;
147 + transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
148 +}
149 +
150 +.primary-button {
151 + border: 1px solid transparent;
152 + background: #0e7490;
153 + color: #ffffff;
154 + box-shadow: 0 10px 22px rgba(14, 116, 144, 0.24);
155 +}
156 +
157 +.primary-button:hover:not(:disabled) {
158 + background: #155e75;
159 + transform: translateY(-1px);
160 +}
161 +
162 +.primary-button:disabled,
163 +.wallet-select:disabled {
164 + cursor: not-allowed;
165 + opacity: 0.58;
166 +}
167 +
168 +.status {
169 + overflow-wrap: anywhere;
170 + color: #667085;
171 + font-size: 13px;
172 + font-weight: 700;
173 +}
174 +
175 +.preview {
176 + position: relative;
177 + min-height: 500px;
178 + overflow: hidden;
179 + background:
180 + linear-gradient(140deg, rgba(14, 116, 144, 0.14), transparent 34%),
181 + linear-gradient(42deg, rgba(180, 83, 9, 0.17), transparent 42%),
182 + #edf2f6;
183 +}
184 +
185 +.preview.is-active {
186 + height: min(680px, calc(100vh - 84px));
187 + min-height: 560px;
188 + background: #ffffff;
189 +}
190 +
191 +.preview-topbar {
192 + display: flex;
193 + justify-content: space-between;
194 + gap: 12px;
195 + align-items: center;
196 + height: 42px;
197 + padding: 0 16px;
198 + border-bottom: 1px solid rgba(24, 32, 51, 0.10);
199 + background: rgba(255, 255, 255, 0.72);
200 +}
201 +
202 +.window-controls {
203 + display: flex;
204 + gap: 7px;
205 + align-items: center;
206 + min-width: 44px;
207 +}
208 +
209 +.window-controls span {
210 + width: 9px;
211 + height: 9px;
212 + border-radius: 999px;
213 + background: #94a3b8;
214 +}
215 +
216 +.window-controls span:nth-child(1) {
217 + background: #b45309;
218 +}
219 +
220 +.window-controls span:nth-child(2) {
221 + background: #0e7490;
222 +}
223 +
224 +.window-controls span:nth-child(3) {
225 + background: #475569;
226 +}
227 +
228 +.viewer-title {
229 + min-width: 0;
230 + overflow: hidden;
231 + color: #334155;
232 + font-size: 12px;
233 + font-weight: 800;
234 + text-overflow: ellipsis;
235 + white-space: nowrap;
236 +}
237 +
238 +.viewer-reset {
239 + min-width: 44px;
240 + border: 0;
241 + background: transparent;
242 + color: #0e7490;
243 + cursor: pointer;
244 + font-size: 12px;
245 + font-weight: 800;
246 +}
247 +
248 +.viewer-reset[hidden] {
249 + display: block;
250 + visibility: hidden;
251 +}
252 +
253 +.preview-body {
254 + position: absolute;
255 + inset: 42px 0 0;
256 + display: grid;
257 + place-items: center;
258 + gap: 18px;
259 + align-content: center;
260 + padding: 28px;
261 + text-align: center;
262 +}
263 +
264 +.preview-body::before {
265 + position: absolute;
266 + inset: 36px;
267 + content: "";
268 + border: 1px dashed rgba(71, 85, 105, 0.28);
269 + border-radius: 8px;
270 + background:
271 + repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.34) 0 10px, transparent 10px 20px),
272 + rgba(255, 255, 255, 0.32);
273 +}
274 +
275 +.lock-mark {
276 + position: relative;
277 + z-index: 1;
278 + display: grid;
279 + width: 92px;
280 + height: 92px;
281 + place-items: center;
282 + border-radius: 8px;
283 + background: #121826;
284 + color: #f8fafc;
285 + font-size: 30px;
286 + font-weight: 850;
287 +}
288 +
289 +.preview-body > div:last-child {
290 + position: relative;
291 + z-index: 1;
292 +}
293 +
294 +.preview-label {
295 + color: #0e7490;
296 + font-size: 13px;
297 + font-weight: 800;
298 + text-transform: uppercase;
299 +}
300 +
301 +.preview-title {
302 + margin-top: 8px;
303 + color: #182033;
304 + font-size: 22px;
305 + font-weight: 800;
306 +}
307 +
308 +.preview-body[hidden],
309 +.payment-frame[hidden] {
310 + display: none;
311 +}
312 +
313 +.payment-frame {
314 + position: absolute;
315 + inset: 42px 0 0;
316 + display: block;
317 + width: 100%;
318 + height: calc(100% - 42px);
319 + border: 0;
320 + background: #f8fafc;
321 +}
322 +
323 +@media (max-width: 860px) {
324 + .shell {
325 + width: min(100% - 20px, 640px);
326 + padding: 18px 0;
327 + }
328 +
329 + .app-surface {
330 + grid-template-columns: 1fr;
331 + }
332 +
333 + .summary,
334 + .preview {
335 + min-height: auto;
336 + }
337 +
338 + h1 {
339 + max-width: none;
340 + font-size: 36px;
341 + }
342 +
343 + .preview {
344 + height: 360px;
345 + }
346 +
347 + .preview.is-active {
348 + height: min(660px, calc(100vh - 36px));
349 + min-height: 560px;
350 + }
351 +}
352 +
353 +@media (max-width: 520px) {
354 + .summary {
355 + padding: 20px;
356 + }
357 +
358 + .actions {
359 + flex-direction: column;
360 + align-items: stretch;
361 + }
362 +
363 + .primary-button {
364 + width: 100%;
365 + }
366 +
367 + .preview {
368 + height: 310px;
369 + }
370 +
371 + .preview.is-active {
372 + height: min(620px, calc(100vh - 28px));
373 + min-height: 520px;
374 + }
375 +
376 + .preview-body::before {
377 + inset: 18px;
378 + }
379 +}
cmd/portal-tunnel/README.md
+2 -2
@@ -173,8 +173,8 @@ Common flags:
173 --thumbnail Service thumbnail URL metadata
174 --owner Service owner metadata
175 --hide Hide service from relay listing screens
176 ---x402-pay-to Sui payment recipient address for this tunnel
177 ---x402-price Sui x402 price mapping in PATH=PRICE form; repeatable
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
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
cmd/portal-tunnel/main.go
+3 -3
@@ -89,8 +89,8 @@ func runExposeCommand(args []string) error {
89 utils.StringFlag(fs, &flags.owner, "owner", "", "Service owner metadata")
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 payment recipient address for this tunnel")
93 - utils.RepeatedStringFlag(fs, &flags.x402Prices, "x402-price", "Sui x402 price mapping in PATH=PRICE form; repeat to price multiple HTTP routes")
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")
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")
@@ -135,7 +135,7 @@ func runExposeCommand(args []string) error {
135 for _, raw := range flags.x402Prices {
136 prefix, price, ok := strings.Cut(raw, "=")
137 if !ok {
138 - return fmt.Errorf("--x402-price %q: expected PATH=PRICE", raw)
138 + return fmt.Errorf("--x402-price %q: expected PATH=ATOMIC_AMOUNT", raw)
139 }
140 prefix = strings.TrimSpace(prefix)
141 if prefix == "" {
docs/src/routes/api-reference/+page.md
+3 -2
@@ -118,8 +118,9 @@ These Sui-only endpoints are available only when
118 `X402_ENABLED=true`. They are served by the embedded
119 `gosuda/x402-facilitator` handler and do not use the Portal JSON envelope.
120 Portal selects Sui mainnet by default and Sui testnet when `X402_TESTNET=true`.
121 -`X402_PAY_TO` is only the relay-owned payment recipient. Tunnel payment
122 -settings are local tunnel configuration and are not part of the relay lease API.
121 +Portal accepts only USDC gasless stablecoin address-balance payments.
122 +`X402_PAY_TO` is the relay-owned payment recipient. Tunnel payment recipients
123 +are local tunnel configuration and are not part of the relay lease API.
124
125 | Method | Path | Auth | Body | Response |
126 |--------|------|------|------|----------|
docs/src/routes/cli-reference/+page.md
+3 -4
@@ -97,8 +97,8 @@ not supported.
97 | `--thumbnail` | string | | Service thumbnail URL metadata |
98 | `--owner` | string | | Service owner metadata |
99 | `--hide` | bool | `false` | Hide service from relay listing screens |
100 -| `--x402-pay-to` | string | | Sui payment recipient address for this tunnel |
101 -| `--x402-price` | string | | Sui x402 price mapping in `PATH=PRICE` form; repeatable; requires `--http-route` and `--x402-pay-to` |
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` |
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 |
@@ -113,8 +113,7 @@ not supported.
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
116 - tunnel-owned `--x402-pay-to`; relay `X402_PAY_TO` is not used as a tunnel
117 - default.
116 + tunnel-owned `--x402-pay-to`.
117
118 ### Examples
119
docs/src/routes/configuration/+page.md
+4 -4
@@ -162,8 +162,8 @@ The `portal expose` subcommand accepts the following flags. Flags that read from
162 | `--owner` | | string | | Service owner metadata |
163 | `--thumbnail` | | string | | Service thumbnail URL metadata |
164 | `--hide` | | bool | `false` | Hide service from relay listing screens |
165 -| `--x402-pay-to` | | string | | Sui payment recipient address for this tunnel |
166 -| `--x402-price` | | string | | Sui x402 price mapping in `PATH=PRICE` form; repeatable; requires `--http-route` and `--x402-pay-to` |
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` |
167
168 ### Routing
169
@@ -261,8 +261,8 @@ Tunnel fields mirror `portal expose` flags:
261 | `identity_json` | string | Identity JSON payload; overrides `identity_path` contents and is persisted there when both are set |
262 | `udp`, `udp_addr`, `tcp` | bool/string | UDP and raw TCP relay options |
263 | `description`, `tags`, `owner`, `thumbnail`, `hide` | mixed | Lease metadata shown by relays |
264 -| `x402_pay_to` | string | Tunnel-owned Sui x402 payment recipient for priced HTTP routes |
265 -| `http_routes[].x402_price` | string | Optional Sui x402 price for one HTTP route prefix; requires `x402_pay_to` |
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` |
266 For a task-oriented walkthrough, see [Portal Agent](/portal-agent).
267
268 ### `identity.json`
docs/src/routes/portal-agent/+page.md
+3 -4
@@ -174,8 +174,8 @@ Common fields:
174 | `multi_hop_depth` | Automatically choose one multi-hop route with this depth |
175 | `ban_mitm` | Ban relays when the TLS self-probe detects termination; defaults to warning-only |
176 | `description`, `tags`, `owner`, `thumbnail`, `hide` | Public relay metadata |
177 -| `x402_pay_to` | Tunnel-owned Sui x402 recipient for priced HTTP routes |
178 -| `http_routes[].x402_price` | Optional Sui x402 price for one HTTP route prefix |
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 |
179
180 Constraints match `portal expose`:
181
@@ -185,8 +185,7 @@ Constraints match `portal expose`:
185 - `multi_hop` cannot be combined with `multi_hop_depth`.
186 - Multi-hop currently supports only the default stream transport, not UDP or raw
187 TCP port mode.
188 -- `http_routes[].x402_price` requires `x402_pay_to`; relay `X402_PAY_TO` is
189 - independent and is not used as a tunnel default.
188 +- `http_routes[].x402_price` requires `x402_pay_to`.
189
190 ## Identity Layout
191
docs/src/routes/self-hosting/+page.md
+4 -4
@@ -99,10 +99,10 @@ environment:
99 ```
100
101 This serves `/api/x402/supported`, `/api/x402/verify`, and `/api/x402/settle`.
102 -Portal payments intentionally support only Sui mainnet and testnet. Route-level
103 -payment enforcement is configured separately from the facilitator endpoint. The
104 -relay `X402_PAY_TO` address is for relay-owned resources; tunnel apps set their
105 -own recipient with `portal expose --x402-pay-to`.
102 +Portal payments intentionally support only Sui mainnet/testnet USDC through the
103 +gasless stablecoin address-balance flow. Route-level payment enforcement is
104 +configured separately by the tunnel with `portal expose --x402-pay-to`; relay
105 +`X402_PAY_TO` is reserved for relay-owned resources.
106
107 ## Connecting Your Tunnel
108
go.mod
+1 -1
@@ -16,7 +16,7 @@ require (
16 github.com/go-acme/lego/v4 v4.34.0
17 github.com/go-jose/go-jose/v4 v4.1.4
18 github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008
19 - github.com/gosuda/x402-facilitator v0.0.1
19 + github.com/gosuda/x402-facilitator v0.0.3-0.20260604031337-6baece37375a
20 github.com/hashicorp/yamux v0.1.2
21 github.com/hetznercloud/hcloud-go/v2 v2.40.0
22 github.com/knadh/koanf/parsers/toml/v2 v2.2.0
go.sum
+2
@@ -220,6 +220,8 @@ github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008 h1:KuP/5VlPJw
220 github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
221 github.com/gosuda/x402-facilitator v0.0.1 h1:Jo4ctVestDMw6B4dkvodZLj6a5rTt6efpoK4VzWoO5k=
222 github.com/gosuda/x402-facilitator v0.0.1/go.mod h1:4hLowxzMiNVcLInkoD9BUDuGbzl86Vj3nO++QXh8OYg=
223 +github.com/gosuda/x402-facilitator v0.0.3-0.20260604031337-6baece37375a h1:AQoH9Wigm4LhKgHLDTP1/4fnuv7WXC9HA7HzvPEHbKY=
224 +github.com/gosuda/x402-facilitator v0.0.3-0.20260604031337-6baece37375a/go.mod h1:4hLowxzMiNVcLInkoD9BUDuGbzl86Vj3nO++QXh8OYg=
225 github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac=
226 github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc=
227 github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
portal/x402/x402.go
+344 -1
@@ -1,13 +1,19 @@
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 facilitatorapi "github.com/gosuda/x402-facilitator/api"
14 facilitatorcore "github.com/gosuda/x402-facilitator/facilitator"
15 + suischeme "github.com/gosuda/x402-facilitator/scheme/sui"
16 + facilitatortypes "github.com/gosuda/x402-facilitator/types"
17
18 "github.com/gosuda/portal-tunnel/v2/types"
19 )
@@ -15,6 +21,12 @@ import (
21 const (
22 MainnetNetwork = "sui:mainnet"
23 TestnetNetwork = "sui:testnet"
24 +
25 + defaultMaxTimeoutSeconds = 60
26 + paymentRequiredHeader = "PAYMENT-REQUIRED"
27 + paymentResponseHeader = "PAYMENT-RESPONSE"
28 + xPaymentHeader = "X-PAYMENT"
29 + paymentSignatureHeader = "PAYMENT-SIGNATURE"
30 )
31
32 var networkDisplayNames = map[string]string{
@@ -41,10 +53,341 @@ func MountFacilitator(mux *http.ServeMux, cfg FacilitatorConfig) error {
53 if mux == nil {
54 return errors.New("x402 facilitator requires an api mux")
55 }
44 - facilitator, err := facilitatorcore.NewSuiFacilitator(Network(cfg.Testnet), "", "")
56 + facilitator, err := NewUSDCFacilitator(Network(cfg.Testnet))
57 if err != nil {
58 return fmt.Errorf("create sui x402 facilitator: %w", err)
59 }
60 mux.Handle(types.PathX402Facilitator+"/", http.StripPrefix(types.PathX402Facilitator, facilitatorapi.NewServer(facilitator)))
61 return nil
62 }
63 +
64 +func USDCAsset(network string) (string, error) {
65 + network = strings.ToLower(strings.TrimSpace(network))
66 + asset, ok := suischeme.GetGaslessStablecoinType(network, "USDC")
67 + if !ok {
68 + return "", fmt.Errorf("USDC is not gasless stablecoin allowlisted on %s", network)
69 + }
70 + return asset, nil
71 +}
72 +
73 +func NewUSDCFacilitator(network string) (facilitatorcore.Facilitator, error) {
74 + network = strings.ToLower(strings.TrimSpace(network))
75 + if network == "" {
76 + network = MainnetNetwork
77 + }
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 + }
212 + }
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")
230 + }
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 + }
242 + }
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
327 + }
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)
393 +}
sdk/expose.go
+1 -1
@@ -525,7 +525,7 @@ func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
525 // RunHTTPRoutes serves path-routed HTTP upstreams through the exposure.
526 func (e *Exposure) RunHTTPRoutes(ctx context.Context, routes []HTTPRoute, localAddr string) error {
527 cfg := e.Config()
528 - handler, err := newHTTPRouteHandler(routes, cfg.Identity, cfg.Metadata, cfg.X402PayTo)
528 + handler, err := newHTTPRouteHandler(routes, cfg.X402PayTo)
529 if err != nil {
530 return err
531 }
sdk/http.go
+69 -11
@@ -19,7 +19,7 @@ import (
19 "github.com/andybalholm/brotli"
20 "github.com/rs/zerolog/log"
21
22 - "github.com/gosuda/portal-tunnel/v2/types"
22 + "github.com/gosuda/portal-tunnel/v2/portal/x402"
23 "github.com/gosuda/portal-tunnel/v2/utils"
24 )
25
@@ -132,7 +132,7 @@ type HTTPRoute struct {
132 Prefix string
133 // Upstream is the target HTTP URL, or a loopback host:port shorthand.
134 Upstream string
135 - // X402Price enables Sui x402 payment for this public path prefix.
135 + // X402Price enables Sui USDC x402 payment for this public path prefix.
136 X402Price string
137 }
138
@@ -143,11 +143,11 @@ type httpRoute struct {
143 upstreamPath string
144 upstreamPathSlash string
145 upstreamDomain string
146 - x402Price string
146 + x402 *x402.Gate
147 handler http.Handler
148 }
149
150 -func newHTTPRouteHandler(routeConfigs []HTTPRoute, tunnelIdentity types.Identity, metadata types.LeaseMetadata, x402PayTo string) (http.Handler, error) {
150 +func newHTTPRouteHandler(routeConfigs []HTTPRoute, x402PayTo string) (http.Handler, error) {
151 if len(routeConfigs) == 0 {
152 return nil, errors.New("at least one http route is required")
153 }
@@ -156,18 +156,15 @@ func newHTTPRouteHandler(routeConfigs []HTTPRoute, tunnelIdentity types.Identity
156 routes := make([]*httpRoute, 0, len(routeConfigs))
157 seen := make(map[string]struct{}, len(routeConfigs))
158 for _, routeConfig := range routeConfigs {
159 - route, err := newHTTPRoute(routeConfig)
159 + route, err := newHTTPRoute(routeConfig, x402PayTo)
160 if err != nil {
161 return nil, err
162 }
163 - if route.x402Price != "" && x402PayTo == "" {
164 - return nil, fmt.Errorf("http route %q x402 price requires x402 pay-to", route.prefix)
165 - }
163 if _, ok := seen[route.prefix]; ok {
164 return nil, fmt.Errorf("duplicate http route prefix %q", route.prefix)
165 }
166 seen[route.prefix] = struct{}{}
170 - route.handler = route.newReverseProxy()
167 + route.handler = route.newHandler()
168 routes = append(routes, route)
169 }
170
@@ -193,7 +190,7 @@ func newHTTPRouteHandler(routeConfigs []HTTPRoute, tunnelIdentity types.Identity
190 }), nil
191 }
192
196 -func newHTTPRoute(routeConfig HTTPRoute) (*httpRoute, error) {
193 +func newHTTPRoute(routeConfig HTTPRoute, x402PayTo string) (*httpRoute, error) {
194 prefix := strings.TrimSpace(routeConfig.Prefix)
195 if prefix == "" {
196 return nil, errors.New("http route prefix is required")
@@ -233,7 +230,6 @@ func newHTTPRoute(routeConfig HTTPRoute) (*httpRoute, error) {
230 upstream: upstream,
231 upstreamPath: upstream.Path,
232 upstreamDomain: utils.NormalizeHostname(upstream.Hostname()),
236 - x402Price: strings.TrimSpace(routeConfig.X402Price),
233 }
234 if prefix != "/" {
235 route.prefixSlash = prefix + "/"
@@ -241,14 +237,53 @@ func newHTTPRoute(routeConfig HTTPRoute) (*httpRoute, error) {
237 if upstream.Path != "/" {
238 route.upstreamPathSlash = upstream.Path + "/"
239 }
240 +
241 + x402Price := strings.TrimSpace(routeConfig.X402Price)
242 + if x402Price != "" {
243 + if x402PayTo == "" {
244 + return nil, fmt.Errorf("http route %q x402 price requires x402 pay-to", route.prefix)
245 + }
246 + gate, err := x402.NewUSDCGate(x402.GateConfig{
247 + Network: x402.MainnetNetwork,
248 + PayTo: x402PayTo,
249 + Amount: x402Price,
250 + })
251 + if err != nil {
252 + return nil, fmt.Errorf("http route %q x402 payment: %w", route.prefix, err)
253 + }
254 + route.x402 = gate
255 + }
256 return route, nil
257 }
258
259 +func (r *httpRoute) newHandler() http.Handler {
260 + proxy := r.newReverseProxy()
261 + if r.x402 == nil {
262 + return proxy
263 + }
264 + 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)
268 + return
269 + }
270 + proxy.ServeHTTP(w, req.WithContext(x402.ContextWithVerifiedPayment(req.Context(), payment)))
271 + })
272 +}
273 +
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
286 + }
287 log.Error().Err(err).
288 Str("route_prefix", r.prefix).
289 Str("upstream", r.upstream.String()).
@@ -263,6 +298,9 @@ func (r *httpRoute) rewriteRequest(pr *httputil.ProxyRequest) {
298 pr.Out.URL.RawQuery = pr.In.URL.RawQuery
299 pr.SetURL(r.upstream)
300 pr.SetXForwarded()
301 + if r.x402 != nil {
302 + x402.StripPaymentHeaders(pr.Out.Header)
303 + }
304
305 // SetXForwarded checks pr.In.TLS, but behind a TLS-terminating proxy
306 // the inbound X-Forwarded-Proto carries the real client scheme.
@@ -287,6 +325,10 @@ func (r *httpRoute) rewriteResponse(resp *http.Response) error {
325 publicHost := resp.Request.Header.Get("X-Forwarded-Host")
326 publicScheme := resp.Request.Header.Get("X-Forwarded-Proto")
327
328 + if err := r.settleX402Response(resp); err != nil {
329 + return err
330 + }
331 +
332 location := header.Get("Location")
333 if location != "" {
334 parsed, err := url.Parse(location)
@@ -359,6 +401,22 @@ func (r *httpRoute) rewriteResponse(resp *http.Response) error {
401 return nil
402 }
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 == "/" {
sdk/http_test.go
+3 -5
@@ -5,8 +5,6 @@ import (
5 "net/http/httptest"
6 "strings"
7 "testing"
8 -
9 - "github.com/gosuda/portal-tunnel/v2/types"
8 )
9
10 func TestServeCompressedHTTPChoosesAcceptedEncoding(t *testing.T) {
@@ -146,7 +144,7 @@ func TestHTTPRoutesUseLongestPrefix(t *testing.T) {
144 handler, err := newHTTPRouteHandler([]HTTPRoute{
145 {Prefix: "/", Upstream: rootServer.URL},
146 {Prefix: "/api", Upstream: apiServer.URL},
149 - }, types.Identity{}, types.LeaseMetadata{}, "")
147 + }, "")
148 if err != nil {
149 t.Fatalf("newHTTPRouteHandler() error = %v", err)
150 }
@@ -177,7 +175,7 @@ func TestHTTPRoutesRewriteResponseHeaders(t *testing.T) {
175
176 handler, err := newHTTPRouteHandler([]HTTPRoute{
177 {Prefix: "/app", Upstream: upstreamURL + "/base"},
180 - }, types.Identity{}, types.LeaseMetadata{}, "")
178 + }, "")
179 if err != nil {
180 t.Fatalf("newHTTPRouteHandler() error = %v", err)
181 }
@@ -201,7 +199,7 @@ func TestHTTPRoutesRejectDuplicateNormalizedPrefixes(t *testing.T) {
199 _, err := newHTTPRouteHandler([]HTTPRoute{
200 {Prefix: "/api", Upstream: "127.0.0.1:3001"},
201 {Prefix: "/api/", Upstream: "127.0.0.1:3002"},
204 - }, types.Identity{}, types.LeaseMetadata{}, "")
202 + }, "")
203 if err == nil {
204 t.Fatal("newHTTPRouteHandler() error = nil, want duplicate prefix error")
205 }