Skip to content

Commit 7b5fbc3

Browse files
authored
Merge pull request #288 from gosuda/feat/casper-x402-tunnel
Feat/casper x402 tunnel
2 parents 79026b4 + 7c1ebf7 commit 7b5fbc3

18 files changed

Lines changed: 413 additions & 184 deletions

File tree

cmd/portal-tunnel/README.md

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,14 @@ portal expose --name paid-app \
6565
--x402-pay-to 0x...
6666
```
6767

68-
Routed HTTP serves `/x402/client.js` and `/x402/prepare` on the tunnel origin so
69-
an upstream browser frontend can run the same in-page Sui wallet payment flow as
70-
the standalone payment app. Native clients should use `/x402/prepare` directly
71-
and send the signed payload as `X-PAYMENT`. The tunnel still verifies and
72-
settles payment before proxying the paid route. Paid routes use Sui mainnet by
73-
default; add `--x402-testnet` to use Sui testnet.
68+
Routed HTTP serves `/x402/client.js` and `/x402/prepare` on the tunnel origin
69+
for the Sui wallet flow. Casper clients instead consume the protected route's
70+
402 requirements, sign with an external Casper x402 SDK, and retry with
71+
`PAYMENT-SIGNATURE` or `X-PAYMENT`; Portal settles it through the configured
72+
Casper facilitator before proxying the request.
73+
The hosted CSPR.cloud facilitator requires `CSPR_CLOUD_API_KEY`, which Portal
74+
sends as its server-side authorization token. A custom unauthenticated
75+
facilitator does not require the token.
7476

7577
Raw TCP and UDP:
7678

@@ -83,7 +85,7 @@ portal expose localhost:8080 --udp --udp-addr localhost:19132 --name game
8385

8486
```text
8587
portal expose [flags] <target>
86-
portal expose [flags] --http-route "PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]" [...]
88+
portal expose [flags] --http-route "PATH=UPSTREAM [METHOD[,METHOD...]:PAYMENT_AMOUNT]" [...]
8789
portal list [flags]
8890
portal agent run [flags]
8991
portal agent dashboard [flags]
@@ -111,9 +113,13 @@ Common `portal expose` flags:
111113
--owner Service owner metadata
112114
--hide Hide service from relay listing screens
113115
--serve Serve a local static site: a directory (served with index.html) or an HTML file (folder served with that file as SPA/CSR entry)
114-
--http-route HTTP route mapping in PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT] form
115-
--x402-pay-to Sui USDC payment recipient address for this tunnel
116-
--x402-testnet Use Sui testnet for tunnel x402 payments
116+
--http-route HTTP route mapping in PATH=UPSTREAM [METHOD[,METHOD...]:PAYMENT_AMOUNT] form
117+
--x402-pay-to Payment recipient address for this tunnel
118+
--x402-testnet Use Sui testnet when --x402-network is omitted
119+
--x402-network Optional Sui or Casper CAIP-2 network
120+
--x402-asset wCSPR CEP-18 contract hash required by Casper
121+
--x402-endpoint Optional Sui RPC or Casper facilitator endpoint; repeatable
122+
--x402-facilitator-token Casper facilitator authorization token; defaults to CSPR_CLOUD_API_KEY
117123
--tcp Request a dedicated raw TCP port on the relay
118124
--udp Enable public UDP relay
119125
--udp-addr Local UDP target
@@ -132,11 +138,12 @@ portal agent restart
132138
```
133139

134140
The dashboard can edit basic tunnel settings, relays, and multi-hop routes. Add
135-
Tunnel opens a small form for name, target or HTTP routes, x402 pay-to/testnet,
141+
Tunnel opens a small form for name, target or HTTP routes, x402 payment settings,
136142
relays, discovery, and max active relays. After creation, routed HTTP paths,
137143
route-level x402 amounts, payment network, and discovery mode are read-only in
138-
the Settings pane. Edit `http_routes`, `x402_pay_to`, `x402_testnet`, or
139-
`discovery` in TOML, then restart the agent or tunnel to change them.
144+
the Settings pane. Edit `http_routes`, `x402_pay_to`, `x402_testnet`,
145+
`x402_network`, `x402_asset`, `x402_endpoints`, `x402_facilitator_token`, or `discovery` in TOML, then
146+
restart the agent or tunnel to change them.
140147

141148
## Constraints
142149

@@ -146,9 +153,10 @@ the Settings pane. Edit `http_routes`, `x402_pay_to`, `x402_testnet`, or
146153
(`..`) is refused, but a symlink inside the folder that points outside it is
147154
still followed, so only serve folders you trust.
148155
- `--http-route` cannot be combined with `--udp`.
149-
- Route payment amounts are USDC values such as `0.01`, are part of
150-
`--http-route`, and require `--x402-pay-to`; add `--x402-testnet` for Sui
151-
testnet, otherwise payments use Sui mainnet.
156+
- Route payment amounts such as `0.01` are part of `--http-route` and require
157+
`--x402-pay-to`. Sui is the default; Casper additionally requires
158+
`--x402-network casper:...` and the wCSPR contract in `--x402-asset`.
159+
The default CSPR.cloud facilitator also requires `CSPR_CLOUD_API_KEY`.
152160
- `--multi-hop` cannot be combined with `--multi-hop-depth`.
153161
- Multi-hop currently supports only the default SNI TLS stream transport.
154162
- `--tcp` and `--udp` require matching relay transport support.

cmd/portal-tunnel/agent/config.go

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,28 +39,32 @@ type AgentConfig struct {
3939
}
4040

4141
type TunnelConfig struct {
42-
ID string `koanf:"id"`
43-
Name string `koanf:"name"`
44-
TargetAddr string `koanf:"target"`
45-
HTTPRoutes []HTTPRouteConfig `koanf:"http_routes"`
46-
RelayURLs []string `koanf:"relays"`
47-
Discovery *bool `koanf:"discovery"`
48-
IdentityPath string `koanf:"identity_path"`
49-
IdentityJSON string `koanf:"identity_json"`
50-
UDPEnabled bool `koanf:"udp"`
51-
UDPAddr string `koanf:"udp_addr"`
52-
TCPEnabled bool `koanf:"tcp"`
53-
MultiHop []string `koanf:"multi_hop"`
54-
MultiHopDepth int `koanf:"multi_hop_depth"`
55-
BanMITM *bool `koanf:"ban_mitm"`
56-
MaxActiveRelays int `koanf:"max_active_relays"`
57-
Description string `koanf:"description"`
58-
Tags []string `koanf:"tags"`
59-
Owner string `koanf:"owner"`
60-
Thumbnail string `koanf:"thumbnail"`
61-
Hide bool `koanf:"hide"`
62-
X402PayTo string `koanf:"x402_pay_to"`
63-
X402Testnet bool `koanf:"x402_testnet"`
42+
ID string `koanf:"id"`
43+
Name string `koanf:"name"`
44+
TargetAddr string `koanf:"target"`
45+
HTTPRoutes []HTTPRouteConfig `koanf:"http_routes"`
46+
RelayURLs []string `koanf:"relays"`
47+
Discovery *bool `koanf:"discovery"`
48+
IdentityPath string `koanf:"identity_path"`
49+
IdentityJSON string `koanf:"identity_json"`
50+
UDPEnabled bool `koanf:"udp"`
51+
UDPAddr string `koanf:"udp_addr"`
52+
TCPEnabled bool `koanf:"tcp"`
53+
MultiHop []string `koanf:"multi_hop"`
54+
MultiHopDepth int `koanf:"multi_hop_depth"`
55+
BanMITM *bool `koanf:"ban_mitm"`
56+
MaxActiveRelays int `koanf:"max_active_relays"`
57+
Description string `koanf:"description"`
58+
Tags []string `koanf:"tags"`
59+
Owner string `koanf:"owner"`
60+
Thumbnail string `koanf:"thumbnail"`
61+
Hide bool `koanf:"hide"`
62+
X402PayTo string `koanf:"x402_pay_to"`
63+
X402Testnet bool `koanf:"x402_testnet"`
64+
X402Network string `koanf:"x402_network"`
65+
X402Asset string `koanf:"x402_asset"`
66+
X402Endpoints []string `koanf:"x402_endpoints"`
67+
X402FacilitatorToken string `koanf:"x402_facilitator_token"`
6468
}
6569

6670
type HTTPRouteConfig struct {
@@ -221,6 +225,10 @@ func tunnelConfigDocumentMap(cfg TunnelConfig) map[string]any {
221225
if cfg.X402Testnet {
222226
out["x402_testnet"] = cfg.X402Testnet
223227
}
228+
addStringDocumentField(out, "x402_network", cfg.X402Network)
229+
addStringDocumentField(out, "x402_asset", cfg.X402Asset)
230+
addStringSliceDocumentField(out, "x402_endpoints", cfg.X402Endpoints)
231+
addStringDocumentField(out, "x402_facilitator_token", cfg.X402FacilitatorToken)
224232
return out
225233
}
226234

@@ -268,6 +276,10 @@ func (cfg *Config) ApplyDefaults(configPath string) error {
268276
t := &cfg.Tunnels[i]
269277
t.ID = strings.TrimSpace(t.ID)
270278
t.Name = strings.TrimSpace(t.Name)
279+
t.X402Network = strings.ToLower(strings.TrimSpace(t.X402Network))
280+
t.X402Asset = strings.TrimSpace(t.X402Asset)
281+
t.X402Endpoints = compactStrings(t.X402Endpoints)
282+
t.X402FacilitatorToken = strings.TrimSpace(t.X402FacilitatorToken)
271283
if t.ID == "" {
272284
t.ID = t.Name
273285
}
@@ -372,9 +384,22 @@ func (cfg TunnelConfig) Validate() error {
372384
return fmt.Errorf("tunnel %q http route %q methods require amount", cfg.ID, strings.TrimSpace(route.Prefix))
373385
}
374386
}
387+
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(cfg.X402Network)), "casper:") && strings.TrimSpace(cfg.X402Asset) == "" {
388+
return fmt.Errorf("tunnel %q Casper payments require x402_asset", cfg.ID)
389+
}
375390
return nil
376391
}
377392

393+
func compactStrings(values []string) []string {
394+
out := values[:0]
395+
for _, value := range values {
396+
if value = strings.TrimSpace(value); value != "" {
397+
out = append(out, value)
398+
}
399+
}
400+
return out
401+
}
402+
378403
func validateAgentPathComponent(name, value string) error {
379404
value = strings.TrimSpace(value)
380405
if value == "" {

cmd/portal-tunnel/agent/dashboard.go

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ const (
6262
agentDashboardAddFieldHTTPRoutes
6363
agentDashboardAddFieldX402PayTo
6464
agentDashboardAddFieldX402Testnet
65+
agentDashboardAddFieldX402Network
66+
agentDashboardAddFieldX402Asset
67+
agentDashboardAddFieldX402Endpoints
6568
agentDashboardAddFieldRelays
6669
agentDashboardAddFieldDiscovery
6770
agentDashboardAddFieldMaxRelays
@@ -100,16 +103,19 @@ type agentDashboardModel struct {
100103
routeDraft []string
101104
draftTunnelID string
102105

103-
addingTunnel bool
104-
addFocus int
105-
addName textinput.Model
106-
addTarget textinput.Model
107-
addHTTPRoutes textinput.Model
108-
addX402PayTo textinput.Model
109-
addX402Testnet textinput.Model
110-
addRelays textinput.Model
111-
addDiscovery textinput.Model
112-
addMaxRelays textinput.Model
106+
addingTunnel bool
107+
addFocus int
108+
addName textinput.Model
109+
addTarget textinput.Model
110+
addHTTPRoutes textinput.Model
111+
addX402PayTo textinput.Model
112+
addX402Testnet textinput.Model
113+
addX402Network textinput.Model
114+
addX402Asset textinput.Model
115+
addX402Endpoints textinput.Model
116+
addRelays textinput.Model
117+
addDiscovery textinput.Model
118+
addMaxRelays textinput.Model
113119

114120
settingsEditTunnelID string
115121
settingsFocus int
@@ -179,6 +185,9 @@ func RunDashboard(configPath, stateDir string) error {
179185
addHTTPRoutes: newAgentDashboardInlineInput("/paid=3001 GET:0.01; /=5173"),
180186
addX402PayTo: newAgentDashboardInlineInput("0x..."),
181187
addX402Testnet: newAgentDashboardInlineInput("false"),
188+
addX402Network: newAgentDashboardInlineInput("casper:casper-test"),
189+
addX402Asset: newAgentDashboardInlineInput("hash-..."),
190+
addX402Endpoints: newAgentDashboardInlineInput("https://x402-facilitator.cspr.cloud"),
182191
addRelays: newAgentDashboardInlineInput("https://portal.example.com"),
183192
addDiscovery: newAgentDashboardInlineInput("true"),
184193
addMaxRelays: newAgentDashboardInlineInput("3"),
@@ -748,6 +757,12 @@ func (m *agentDashboardModel) focusedAddTunnelInput() *textinput.Model {
748757
return &m.addX402PayTo
749758
case agentDashboardAddFieldX402Testnet:
750759
return &m.addX402Testnet
760+
case agentDashboardAddFieldX402Network:
761+
return &m.addX402Network
762+
case agentDashboardAddFieldX402Asset:
763+
return &m.addX402Asset
764+
case agentDashboardAddFieldX402Endpoints:
765+
return &m.addX402Endpoints
751766
case agentDashboardAddFieldRelays:
752767
return &m.addRelays
753768
case agentDashboardAddFieldDiscovery:
@@ -766,6 +781,9 @@ func (m *agentDashboardModel) blurAddTunnelInputs() {
766781
&m.addHTTPRoutes,
767782
&m.addX402PayTo,
768783
&m.addX402Testnet,
784+
&m.addX402Network,
785+
&m.addX402Asset,
786+
&m.addX402Endpoints,
769787
&m.addRelays,
770788
&m.addDiscovery,
771789
&m.addMaxRelays,
@@ -781,6 +799,9 @@ func (m *agentDashboardModel) resetAddTunnelForm() {
781799
&m.addHTTPRoutes,
782800
&m.addX402PayTo,
783801
&m.addX402Testnet,
802+
&m.addX402Network,
803+
&m.addX402Asset,
804+
&m.addX402Endpoints,
784805
&m.addRelays,
785806
} {
786807
input.Reset()
@@ -903,6 +924,9 @@ func (m *agentDashboardModel) resizeInputs(width int) {
903924
&m.addHTTPRoutes,
904925
&m.addX402PayTo,
905926
&m.addX402Testnet,
927+
&m.addX402Network,
928+
&m.addX402Asset,
929+
&m.addX402Endpoints,
906930
&m.addRelays,
907931
&m.addDiscovery,
908932
&m.addMaxRelays,
@@ -1017,6 +1041,15 @@ func (m agentDashboardModel) addTunnelRequest() (types.AgentTunnelRequest, error
10171041
if x402Testnet && !hasPaidRoute {
10181042
return types.AgentTunnelRequest{}, fmt.Errorf("X402 Testnet requires paid routes")
10191043
}
1044+
x402Network := strings.ToLower(strings.TrimSpace(m.addX402Network.Value()))
1045+
x402Asset := strings.TrimSpace(m.addX402Asset.Value())
1046+
x402Endpoints := utils.SplitCSV(m.addX402Endpoints.Value())
1047+
if x402Network != "" && !hasPaidRoute {
1048+
return types.AgentTunnelRequest{}, fmt.Errorf("X402 Network requires paid routes")
1049+
}
1050+
if strings.HasPrefix(x402Network, "casper:") && x402Asset == "" {
1051+
return types.AgentTunnelRequest{}, fmt.Errorf("casper payments require X402 Asset")
1052+
}
10201053

10211054
discoveryRaw := strings.TrimSpace(m.addDiscovery.Value())
10221055
if discoveryRaw == "" {
@@ -1045,6 +1078,9 @@ func (m agentDashboardModel) addTunnelRequest() (types.AgentTunnelRequest, error
10451078
MaxActiveRelays: maxRelays,
10461079
X402PayTo: payTo,
10471080
X402Testnet: x402Testnet,
1081+
X402Network: x402Network,
1082+
X402Asset: x402Asset,
1083+
X402Endpoints: x402Endpoints,
10481084
}, nil
10491085
}
10501086

@@ -1492,6 +1528,9 @@ func (m agentDashboardModel) renderAddTunnelForm(pane *agentDashboardView, width
14921528
{label: "Routes", input: m.addHTTPRoutes, field: agentDashboardAddFieldHTTPRoutes},
14931529
{label: "X402 Pay To", input: m.addX402PayTo, field: agentDashboardAddFieldX402PayTo},
14941530
{label: "X402 Testnet", input: m.addX402Testnet, field: agentDashboardAddFieldX402Testnet},
1531+
{label: "X402 Network", input: m.addX402Network, field: agentDashboardAddFieldX402Network},
1532+
{label: "X402 Asset", input: m.addX402Asset, field: agentDashboardAddFieldX402Asset},
1533+
{label: "X402 Endpoints", input: m.addX402Endpoints, field: agentDashboardAddFieldX402Endpoints},
14951534
{label: "Relays", input: m.addRelays, field: agentDashboardAddFieldRelays},
14961535
{label: "Discovery", input: m.addDiscovery, field: agentDashboardAddFieldDiscovery},
14971536
{label: "Max Relays", input: m.addMaxRelays, field: agentDashboardAddFieldMaxRelays},
@@ -1643,7 +1682,10 @@ func (m agentDashboardModel) renderSettingsInputRows(pane *agentDashboardView, w
16431682
if len(pane.lines)-startLine >= height {
16441683
return
16451684
}
1646-
pane.addMeta(width, 0, "Network", agentDashboardX402Network(tunnel.X402Testnet))
1685+
pane.addMeta(width, 0, "Network", agentDashboardX402Network(tunnel.X402Network, tunnel.X402Testnet))
1686+
}
1687+
if strings.TrimSpace(tunnel.X402Asset) != "" && len(pane.lines)-startLine < height {
1688+
pane.addMeta(width, 0, "Asset", tunnel.X402Asset)
16471689
}
16481690
if len(tunnel.HTTPRoutes) == 0 {
16491691
if len(pane.lines)-startLine >= height {
@@ -2054,7 +2096,10 @@ func agentDashboardHTTPRouteSummary(route types.AgentHTTPRoute) string {
20542096
return prefix + " -> " + upstream
20552097
}
20562098

2057-
func agentDashboardX402Network(testnet bool) string {
2099+
func agentDashboardX402Network(network string, testnet bool) string {
2100+
if network = strings.TrimSpace(network); network != "" {
2101+
return network
2102+
}
20582103
if testnet {
20592104
return "sui:testnet"
20602105
}

cmd/portal-tunnel/agent/manager.go

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,9 @@ func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
259259
MaxActiveRelays: req.MaxActiveRelays,
260260
X402PayTo: strings.TrimSpace(req.X402PayTo),
261261
X402Testnet: req.X402Testnet,
262+
X402Network: strings.ToLower(strings.TrimSpace(req.X402Network)),
263+
X402Asset: strings.TrimSpace(req.X402Asset),
264+
X402Endpoints: compactStrings(append([]string(nil), req.X402Endpoints...)),
262265
}
263266
if slices.ContainsFunc(cfg.Tunnels, func(tunnel TunnelConfig) bool { return tunnel.ID == tunnelCfg.ID }) {
264267
return fmt.Errorf("tunnel %q already exists", tunnelCfg.ID)
@@ -615,6 +618,9 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
615618
MultiHop: append([]string(nil), cfg.MultiHop...),
616619
X402PayTo: strings.TrimSpace(cfg.X402PayTo),
617620
X402Testnet: cfg.X402Testnet,
621+
X402Network: cfg.X402Network,
622+
X402Asset: cfg.X402Asset,
623+
X402Endpoints: append([]string(nil), cfg.X402Endpoints...),
618624
}
619625
if len(cfg.HTTPRoutes) > 0 {
620626
status.HTTPRoutes = make([]types.AgentHTTPRoute, 0, len(cfg.HTTPRoutes))
@@ -697,23 +703,31 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
697703
if cfg.BanMITM != nil {
698704
banMITM = *cfg.BanMITM
699705
}
706+
x402FacilitatorToken := strings.TrimSpace(cfg.X402FacilitatorToken)
707+
if x402FacilitatorToken == "" {
708+
x402FacilitatorToken = strings.TrimSpace(os.Getenv("CSPR_CLOUD_API_KEY"))
709+
}
700710
exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
701-
RelayURLs: append([]string(nil), cfg.RelayURLs...),
702-
Discovery: discovery,
703-
Identity: types.Identity{Name: cfg.Name},
704-
IdentityPath: cfg.IdentityPath,
705-
IdentityJSON: cfg.IdentityJSON,
706-
TargetAddr: cfg.TargetAddr,
707-
UDPAddr: cfg.UDPAddr,
708-
UDPEnabled: cfg.UDPEnabled,
709-
TCPEnabled: cfg.TCPEnabled,
710-
MultiHop: append([]string(nil), cfg.MultiHop...),
711-
MultiHopDepth: cfg.MultiHopDepth,
712-
BanMITM: banMITM,
713-
MaxActiveRelays: cfg.MaxActiveRelays,
714-
Metadata: metadataFromTunnelConfig(cfg),
715-
X402PayTo: cfg.X402PayTo,
716-
X402Testnet: cfg.X402Testnet,
711+
RelayURLs: append([]string(nil), cfg.RelayURLs...),
712+
Discovery: discovery,
713+
Identity: types.Identity{Name: cfg.Name},
714+
IdentityPath: cfg.IdentityPath,
715+
IdentityJSON: cfg.IdentityJSON,
716+
TargetAddr: cfg.TargetAddr,
717+
UDPAddr: cfg.UDPAddr,
718+
UDPEnabled: cfg.UDPEnabled,
719+
TCPEnabled: cfg.TCPEnabled,
720+
MultiHop: append([]string(nil), cfg.MultiHop...),
721+
MultiHopDepth: cfg.MultiHopDepth,
722+
BanMITM: banMITM,
723+
MaxActiveRelays: cfg.MaxActiveRelays,
724+
Metadata: metadataFromTunnelConfig(cfg),
725+
X402PayTo: cfg.X402PayTo,
726+
X402Testnet: cfg.X402Testnet,
727+
X402Network: cfg.X402Network,
728+
X402Asset: cfg.X402Asset,
729+
X402Endpoints: append([]string(nil), cfg.X402Endpoints...),
730+
X402FacilitatorToken: x402FacilitatorToken,
717731
})
718732
if err != nil {
719733
return err

0 commit comments

Comments
 (0)