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
}