diff --git a/cmd/hpphub/main.go b/cmd/hpphub/main.go index ac92c35..824ab24 100644 --- a/cmd/hpphub/main.go +++ b/cmd/hpphub/main.go @@ -13,6 +13,7 @@ import ( "github.com/hpp-io/hpphub-cli/internal/auth" "github.com/hpp-io/hpphub-cli/internal/config" "github.com/hpp-io/hpphub-cli/internal/openclaw" + "github.com/hpp-io/hpphub-cli/internal/topup" "github.com/spf13/cobra" ) @@ -36,6 +37,7 @@ func newCLI() *cobra.Command { modelsCmd(), launchCmd(), setupCmd(), + topupCmd(), uninstallCmd(), ) @@ -584,6 +586,54 @@ func setupTelegram() error { return nil } +// ─── topup ────────────────────────────────────────────────── + +func topupCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "topup ", + Short: "Manage x402 credit auto-top-up", + } + cmd.AddCommand(topupSetupCmd(), topupStatusCmd()) + return cmd +} + +func topupStatusCmd() *cobra.Command { + var hubURL string + cmd := &cobra.Command{ + Use: "status", + Short: "Check whether credit auto-top-up is wired and working", + RunE: func(cmd *cobra.Command, args []string) error { + return topup.Status(hubURL) + }, + } + cmd.Flags().StringVar(&hubURL, "hub-url", "", "Hub URL to check (default: from login)") + return cmd +} + +func topupSetupCmd() *cobra.Command { + var o topup.Options + cmd := &cobra.Command{ + Use: "setup", + Short: "Configure credit auto-top-up from your existing login", + RunE: func(cmd *cobra.Command, args []string) error { + return topup.Setup(o) + }, + } + f := cmd.Flags() + f.StringVar(&o.HubURL, "hub-url", "", "Hub URL (default: from login)") + f.StringVar(&o.Safe, "safe", "", "Safe address that funds payments") + f.StringVar(&o.ResourceServer, "resource-server", "", "Upstream MCP resource server URL") + f.StringVar(&o.RPC, "rpc", "", "HPP RPC URL") + f.StringVar(&o.Network, "network", "", "CAIP-2 network id (e.g. eip155:181228)") + f.StringVar(&o.USDCe, "usdce", "", "USDC.e token address (override; defaults to HPP Sepolia)") + f.StringVar(&o.AllowanceMod, "allowance-module", "", "AllowanceModule address (override; defaults to HPP Sepolia)") + f.BoolVar(&o.EnableAuto, "enable-auto", false, "Also enable periodic auto-top-up") + f.Float64Var(&o.Watermark, "watermark", 2, "Auto-top-up when remaining credit is below this") + f.Float64Var(&o.Target, "target", 5, "Auto-top-up up to this balance") + f.BoolVar(&o.DryRun, "dry-run", false, "Preview changes without writing") + return cmd +} + // ─── uninstall ────────────────────────────────────────────── func uninstallCmd() *cobra.Command { diff --git a/internal/topup/keychain.go b/internal/topup/keychain.go new file mode 100644 index 0000000..879fb21 --- /dev/null +++ b/internal/topup/keychain.go @@ -0,0 +1,117 @@ +package topup + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "regexp" + "strings" + "time" +) + +const delegateAccount = "delegate-default" + +var ( + addrRe = regexp.MustCompile(`0x[0-9a-fA-F]{40}`) + hex64Re = regexp.MustCompile(`^0x[0-9a-fA-F]{64}$`) +) + +// runBridgeBin runs a bridge CLI bin via npx (combined output). Best-effort: +// callers degrade to guidance if npx/network is absent. +func runBridgeBin(args ...string) (string, error) { + return runBridgeBinStdin("", args...) +} + +func runBridgeBinStdin(stdin string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "npx", append([]string{"-y", "-p", bridgePackage}, args...)...) + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin + "\n") + } + out, err := cmd.CombinedOutput() + return string(out), err +} + +// keychainShow returns the existing delegate address, or "" if none/unavailable. +func keychainShow() string { + out, err := runBridgeBin("hpp-x402-keychain", "show", delegateAccount) + if err != nil { + return "" + } + return addrRe.FindString(out) +} + +func keychainGenerate() (string, error) { + out, err := runBridgeBin("hpp-x402-keychain", "generate", delegateAccount) + if err != nil { + return "", fmt.Errorf("keychain generate failed: %w (%s)", err, truncate(out, 200)) + } + if a := addrRe.FindString(out); a != "" { + return a, nil + } + return "", fmt.Errorf("could not parse delegate address") +} + +func keychainImport(hexKey string) (string, error) { + out, err := runBridgeBinStdin(hexKey, "hpp-x402-keychain", "set", delegateAccount, "--stdin") + if err != nil { + return "", fmt.Errorf("keychain import failed: %w (%s)", err, truncate(out, 200)) + } + if a := addrRe.FindString(out); a != "" { + return a, nil + } + return "", fmt.Errorf("could not parse delegate address after import") +} + +func isTTY() bool { + fi, err := os.Stdin.Stat() + return err == nil && fi.Mode()&os.ModeCharDevice != 0 +} + +// resolveDelegate returns the delegate address, letting the user choose how to +// provide the key: +// - existing key present → keep it (Enter), or paste a 0x key to replace +// - no key present → generate (Enter), or paste a 0x key to import +// +// Non-interactive (piped/CI): keep existing if present, else generate. +func resolveDelegate(interactive bool) (string, error) { + existing := keychainShow() + + if !interactive { + if existing != "" { + return existing, nil + } + return keychainGenerate() + } + + if existing != "" { + fmt.Printf("Delegate payment key found: %s\n", existing) + fmt.Print(" Press Enter to keep it, or paste a different 0x<64hex> key to replace: ") + } else { + fmt.Println("No delegate payment key found.") + fmt.Print(" Press Enter to generate one, or paste an existing 0x<64hex> key to import: ") + } + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + line = strings.TrimSpace(line) + + switch { + case line == "" && existing != "": + return existing, nil + case line == "": + return keychainGenerate() + case hex64Re.MatchString(line): + return keychainImport(line) + default: + return "", fmt.Errorf("expected Enter, or a 0x + 64 hex private key") + } +} + +func truncate(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} diff --git a/internal/topup/setup.go b/internal/topup/setup.go new file mode 100644 index 0000000..193bf0a --- /dev/null +++ b/internal/topup/setup.go @@ -0,0 +1,303 @@ +// Package topup wires up x402 credit auto-top-up from the user's existing +// hpphub login, so enabling it is a single command instead of hand-editing +// several config files. +// +// It configures only the *local* pieces (policy + openclaw bridge registration +// + optional auto-top-up task). The one-time on-chain wallet steps (payment key, +// Safe, allowance) can't be a single command and are printed as guidance. +package topup + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/hpp-io/hpphub-cli/internal/config" +) + +const ( + bridgePackage = "@hpp-io/x402-mcp-bridge" + delegateKeychainURI = "keychain://hpp-x402/delegate-default" + apiKeySource = "file:~/.hpphub/config.json#api_key" + + // HPP Sepolia defaults (override via flags for other networks). + defaultAllowanceModule = "0x3CcE72483929e0517Dafc8fD192547B3B65f9b07" + defaultUSDCe = "0x401eCb1D350407f13ba348573E5630B83638E30D" + defaultRPC = "https://sepolia.hpp.io" + defaultNetwork = "eip155:181228" + + defaultMaxPerCall = "5000000" // 5 USDC.e (6 decimals) + defaultCooldownMs = 300000 +) + +// Options for `hpphub topup setup`. +type Options struct { + HubURL string + Safe string + ResourceServer string + RPC string + Network string + USDCe string + AllowanceMod string + EnableAuto bool + Watermark float64 + Target float64 + DryRun bool +} + +// Setup configures credit auto-top-up. See package doc. +func Setup(o Options) error { + cfg, err := config.Load() + if err != nil { + return err + } + if !cfg.IsLoggedIn() { + return fmt.Errorf("not logged in — run 'hpphub login' first") + } + + hub := o.HubURL + if hub == "" { + hub = cfg.GetHubURL() + } + u, perr := url.Parse(hub) + if perr != nil || u.Host == "" { + return fmt.Errorf("invalid hub url: %q", hub) + } + host := strings.ToLower(u.Host) + rpc := orDefault(o.RPC, defaultRPC) + network := orDefault(o.Network, defaultNetwork) + + home, _ := os.UserHomeDir() + + // --- policy.json: host entry (auth header auto from login) --- + policyPath := policyFilePath(home) + policy := readJSONMap(policyPath) + hostEntry := map[string]any{ + "headers": map[string]any{"X-Api-Key": apiKeySource}, + "limits": map[string]any{ + "requireHttps": !isLocal(host), + "maxPerCallAtomic": defaultMaxPerCall, + "cooldownMs": defaultCooldownMs, + }, + } + policy[host] = hostEntry + if _, ok := policy["_defaults"]; !ok { + policy["_defaults"] = map[string]any{ + "allowUnlisted": false, + "limits": map[string]any{"requireHttps": true, "maxPerCallAtomic": "1000000"}, + } + } + + // --- openclaw.json: register/merge the bridge (reuse existing env) --- + ocPath := filepath.Join(home, ".openclaw", "openclaw.json") + oc := readJSONMap(ocPath) + servers := childMap(childMap(oc, "mcp"), "servers") + existing, _ := servers["hpp-x402"].(map[string]any) + existingEnv := map[string]any{} + if existing != nil { + if e, ok := existing["env"].(map[string]any); ok { + existingEnv = e + } + } + reuse := func(k, def string) string { + if v, ok := existingEnv[k].(string); ok && v != "" { + return v + } + return def + } + safe := orDefault(o.Safe, reuse("SAFE_ADDRESS", "")) + resourceServer := orDefault(o.ResourceServer, reuse("RESOURCE_SERVER_URL", "")) + + command := "npx" + var args any = []any{"-y", bridgePackage} + if existing != nil { + if c, ok := existing["command"].(string); ok && c != "" { + command = c + } + if a, ok := existing["args"].([]any); ok && len(a) > 0 { + args = a + } + } + env := map[string]any{ + "DELEGATE_PRIVATE_KEY": reuse("DELEGATE_PRIVATE_KEY", delegateKeychainURI), + "SAFE_ADDRESS": safe, + "ALLOWANCE_MODULE_ADDRESS": orDefault(o.AllowanceMod, reuse("ALLOWANCE_MODULE_ADDRESS", defaultAllowanceModule)), + "USDCE_ADDRESS": orDefault(o.USDCe, reuse("USDCE_ADDRESS", defaultUSDCe)), + "RESOURCE_SERVER_URL": resourceServer, + "HPP_RPC_URL": reuse("HPP_RPC_URL", rpc), + "HPP_NETWORK": reuse("HPP_NETWORK", network), + "LOG_LEVEL": reuse("LOG_LEVEL", "info"), + } + // Omit empty optionals — an empty string fails the bridge's url/address + // validation. RESOURCE_SERVER_URL is optional (local-tools-only when + // unset); SAFE_ADDRESS is only needed for Safe-funded autoTopup. + if resourceServer == "" { + delete(env, "RESOURCE_SERVER_URL") + } + if safe == "" { + delete(env, "SAFE_ADDRESS") + } + srv := map[string]any{ + "command": command, + "args": args, + "env": env, + } + servers["hpp-x402"] = srv + + // --- HEARTBEAT.md (optional auto-top-up) --- + hbPath := filepath.Join(home, ".openclaw", "workspace", "HEARTBEAT.md") + watermark := orFloat(o.Watermark, 2) + target := orFloat(o.Target, 5) + hbContent := heartbeatTask(hub, watermark, target) + + // --- dry-run: preview, write nothing --- + if o.DryRun { + fmt.Println("DRY RUN — no files written") + fmt.Printf("\n• %s\n%s\n", policyPath, indent(toJSON(map[string]any{host: hostEntry}))) + fmt.Printf("\n• %s (mcp.servers.hpp-x402)\n%s\n", ocPath, indent(toJSON(srv))) + if o.EnableAuto { + fmt.Printf("\n• %s\n%s\n", hbPath, indent(hbContent)) + } + return nil + } + + if err := writeJSONMap(policyPath, policy, 0o600); err != nil { + return fmt.Errorf("write policy: %w", err) + } + if err := writeJSONMap(ocPath, oc, 0o600); err != nil { + return fmt.Errorf("write openclaw config: %w", err) + } + if o.EnableAuto { + if err := os.MkdirAll(filepath.Dir(hbPath), 0o755); err != nil { + return err + } + if err := os.WriteFile(hbPath, []byte(hbContent), 0o644); err != nil { + return fmt.Errorf("write HEARTBEAT.md: %w", err) + } + } + + // Resolve the delegate payment key (best-effort): keep an existing key, + // or let the user generate/import one. Surface the address — needed to + // fund/register on a Safe. + delegateAddr, _ := resolveDelegate(isTTY()) + + printSummary(host, safe, resourceServer, delegateAddr, o.EnableAuto) + return nil +} + +func heartbeatTask(hub string, watermark, target float64) string { + base := strings.TrimRight(hub, "/") + return fmt.Sprintf(`# HEARTBEAT.md + +## HPP credit auto-top-up +- Use the x402_http_call tool. GET %s/api/web3/credit/usage and read "remaining". +- If remaining < %g, POST %s/api/web3/credit/topup with body {"amount": }. +- If remaining >= %g, do nothing. Top up at most once per run. +`, base, watermark, base, target, watermark) +} + +func printSummary(host, safe, resourceServer, delegateAddr string, auto bool) { + fmt.Println("✓ credit auto-top-up configured") + fmt.Printf(" • policy: host %s (auth uses your existing hpphub login)\n", host) + fmt.Println(" • openclaw: bridge registered (mcp.servers.hpp-x402)") + if delegateAddr != "" { + fmt.Printf(" • payment wallet (delegate): %s\n", delegateAddr) + } + if auto { + fmt.Println(" • auto-top-up: enabled (HEARTBEAT.md)") + } + fmt.Println("\nNext steps:") + n := 1 + if delegateAddr == "" { + fmt.Printf(" %d. Create the payment wallet key (prints the delegate ADDRESS):\n", n) + fmt.Printf(" npx -y -p %s hpp-x402-keychain generate\n", bridgePackage) + n++ + } + if safe == "" { + fmt.Printf(" %d. Fund payments — either:\n", n) + fmt.Printf(" a) send USDC.e directly to the delegate address above (simplest), or\n") + fmt.Printf(" b) Safe daily-cap: register the delegate + set an allowance + fund the Safe\n") + fmt.Printf(" (npx -y -p %s hpp-x402-safe-setup --help), then re-run with --safe
.\n", bridgePackage) + n++ + } + if resourceServer == "" { + fmt.Println(" (optional) paid compute/MCP tools need an upstream server — set --resource-server .") + } + fmt.Printf(" %d. Restart the gateway: openclaw gateway restart\n", n) + fmt.Println("\nVerify anytime: hpphub topup status") +} + +// ---- helpers ---- + +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} +func orFloat(v, def float64) float64 { + if v == 0 { + return def + } + return v +} +func isLocal(host string) bool { + return strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") +} + +func policyFilePath(home string) string { + if p := os.Getenv("HPP_X402_CRED_FILE"); p != "" { + return expandHome(p, home) + } + dir := os.Getenv("HPP_X402_HOME") + if dir == "" { + dir = filepath.Join(home, ".hpp-x402") + } + return filepath.Join(dir, "policy.json") +} +func expandHome(p, home string) string { + if strings.HasPrefix(p, "~") { + return filepath.Join(home, strings.TrimLeft(p[1:], `/\`)) + } + return p +} + +func readJSONMap(path string) map[string]any { + m := map[string]any{} + if b, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(b, &m) + } + return m +} +func writeJSONMap(path string, m map[string]any, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(b, '\n'), mode) +} +func childMap(m map[string]any, key string) map[string]any { + if v, ok := m[key].(map[string]any); ok { + return v + } + nm := map[string]any{} + m[key] = nm + return nm +} +func toJSON(v any) string { + b, _ := json.MarshalIndent(v, "", " ") + return string(b) +} +func indent(s string) string { + lines := strings.Split(s, "\n") + for i := range lines { + lines[i] = " " + lines[i] + } + return strings.Join(lines, "\n") +} diff --git a/internal/topup/status.go b/internal/topup/status.go new file mode 100644 index 0000000..bc1e30a --- /dev/null +++ b/internal/topup/status.go @@ -0,0 +1,166 @@ +package topup + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/hpp-io/hpphub-cli/internal/config" +) + +// Status checks whether credit auto-top-up is actually wired and working: +// login, delegate key, Hub reachability, delegate balance, gateway. Read-only. +// hubOverride targets a specific Hub (defaults to the logged-in hub_url). +func Status(hubOverride string) error { + cfg, err := config.Load() + if err != nil { + return err + } + fmt.Println("x402 credit top-up — status") + fmt.Println() + + if !cfg.IsLoggedIn() { + fmt.Println(" ✗ not logged in — run 'hpphub login'") + return nil + } + fmt.Printf(" ✓ logged in (%s)\n", cfg.Email) + + home, _ := os.UserHomeDir() + env := bridgeEnv(readJSONMap(filepath.Join(home, ".openclaw", "openclaw.json"))) + rpc := envOr(env, "HPP_RPC_URL", defaultRPC) + usdce := envOr(env, "USDCE_ADDRESS", defaultUSDCe) + hub := cfg.GetHubURL() + if hubOverride != "" { + hub = hubOverride + } + + // bridge registered? + if len(env) == 0 { + fmt.Println(" ✗ bridge not registered in OpenClaw — run 'hpphub topup setup'") + } else { + fmt.Println(" ✓ bridge registered (mcp.servers.hpp-x402)") + } + + // delegate key + addr := keychainShow() + if addr == "" { + fmt.Println(" ✗ delegate key: not set — run 'hpphub topup setup'") + } else { + fmt.Printf(" ✓ delegate key: %s\n", addr) + } + + // Hub reachable + credit + if rem, err := hubUsage(hub, cfg.APIKey); err != nil { + fmt.Printf(" ✗ Hub credit endpoint: %v\n", err) + } else { + fmt.Printf(" ✓ Hub reachable — remaining credit: %.4f\n", rem) + } + + // delegate on-chain USDC.e balance + if addr != "" { + if bal, err := erc20Balance(rpc, usdce, addr); err != nil { + fmt.Printf(" ⚠ delegate USDC.e balance: %v\n", err) + } else { + fmt.Printf(" ✓ delegate USDC.e balance: %.4f\n", bal) + } + } + + // Safe (funding source for autoTopup) + if safe := env["SAFE_ADDRESS"]; safe != "" { + fmt.Printf(" • Safe (autoTopup source): %s\n", safe) + } else { + fmt.Println(" • Safe: not set — delegate is funded directly (no daily-cap autoTopup)") + } + + // gateway + if err := exec.Command("openclaw", "health").Run(); err != nil { + fmt.Println(" ⚠ openclaw gateway: not reachable — run 'openclaw gateway restart'") + } else { + fmt.Println(" ✓ openclaw gateway: running") + } + return nil +} + +func bridgeEnv(oc map[string]any) map[string]string { + out := map[string]string{} + mcp, _ := oc["mcp"].(map[string]any) + servers, _ := mcp["servers"].(map[string]any) + srv, _ := servers["hpp-x402"].(map[string]any) + env, _ := srv["env"].(map[string]any) + for k, v := range env { + if s, ok := v.(string); ok { + out[k] = s + } + } + return out +} + +func envOr(env map[string]string, k, def string) string { + if v := env[k]; v != "" { + return v + } + return def +} + +func hubUsage(hub, apiKey string) (float64, error) { + req, _ := http.NewRequest("GET", strings.TrimRight(hub, "/")+"/api/web3/credit/usage", nil) + req.Header.Set("X-Api-Key", apiKey) + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return 0, fmt.Errorf("HTTP %d", resp.StatusCode) + } + var d struct { + Remaining float64 `json:"remaining"` + } + if err := json.Unmarshal(b, &d); err != nil { + return 0, fmt.Errorf("unexpected response (not the credit endpoint?)") + } + return d.Remaining, nil +} + +func erc20Balance(rpc, token, addr string) (float64, error) { + data := "0x70a08231" + "000000000000000000000000" + strings.ToLower(strings.TrimPrefix(addr, "0x")) + payload, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "eth_call", + "params": []any{map[string]string{"to": token, "data": data}, "latest"}, + }) + resp, err := (&http.Client{Timeout: 10 * time.Second}).Post(rpc, "application/json", bytes.NewReader(payload)) + if err != nil { + return 0, err + } + defer resp.Body.Close() + var r struct { + Result string `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return 0, err + } + if r.Error != nil { + return 0, fmt.Errorf("%s", r.Error.Message) + } + if r.Result == "" || r.Result == "0x" { + return 0, nil + } + n, ok := new(big.Int).SetString(strings.TrimPrefix(r.Result, "0x"), 16) + if !ok { + return 0, fmt.Errorf("bad eth_call result") + } + f, _ := new(big.Float).Quo(new(big.Float).SetInt(n), big.NewFloat(1e6)).Float64() + return f, nil +}