From ad05d310bdd33fa8d906a947841a94d479b9da78 Mon Sep 17 00:00:00 2001 From: "eul.noh" Date: Mon, 8 Jun 2026 19:41:18 +0900 Subject: [PATCH 1/5] feat: add `hpphub topup setup` for one-command credit auto-top-up Configures x402 credit auto-top-up from the user's existing login, so enabling it no longer requires hand-editing config files: - writes the local policy entry (auth header derived from the login) - registers the bridge in the OpenClaw config (reusing any existing values) - --enable-auto opts into periodic auto-top-up - --dry-run previews; re-runs are idempotent Remaining one-time on-chain wallet steps are printed as guidance. Closes #2 --- cmd/hpphub/main.go | 35 +++++ internal/topup/setup.go | 280 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 internal/topup/setup.go diff --git a/cmd/hpphub/main.go b/cmd/hpphub/main.go index ac92c35..c9f784b 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,39 @@ 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()) + 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.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/setup.go b/internal/topup/setup.go new file mode 100644 index 0000000..1560d99 --- /dev/null +++ b/internal/topup/setup.go @@ -0,0 +1,280 @@ +// 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 + 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 + } + } + srv := map[string]any{ + "command": command, + "args": args, + "env": map[string]any{ + "DELEGATE_PRIVATE_KEY": reuse("DELEGATE_PRIVATE_KEY", delegateKeychainURI), + "SAFE_ADDRESS": safe, + "ALLOWANCE_MODULE_ADDRESS": reuse("ALLOWANCE_MODULE_ADDRESS", defaultAllowanceModule), + "USDCE_ADDRESS": 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"), + }, + } + 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) + } + } + + printSummary(host, srv["env"].(map[string]any), safe, resourceServer, 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 string, env map[string]any, safe, resourceServer 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 auto { + fmt.Println(" • auto-top-up: enabled (HEARTBEAT.md)") + } + fmt.Println("\nNext steps:") + n := 1 + if dk, _ := env["DELEGATE_PRIVATE_KEY"].(string); dk == delegateKeychainURI { + fmt.Printf(" %d. Create the payment wallet key:\n", n) + fmt.Printf(" npx -y -p %s hpp-x402-keychain generate\n", bridgePackage) + n++ + } + if safe == "" { + fmt.Printf(" %d. Set up a Safe (https://safe.hpp.io), fund it, authorize the delegate,\n", n) + fmt.Printf(" then re-run with --safe
.\n") + n++ + } + if resourceServer == "" { + fmt.Println(" ! --resource-server is required for the bridge to start (set and re-run).") + } + fmt.Printf(" %d. Restart the gateway: openclaw gateway restart\n", n) +} + +// ---- 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") +} From 3bb5968975b25626826fa08e799868e17d77be9f Mon Sep 17 00:00:00 2001 From: "eul.noh" Date: Mon, 8 Jun 2026 20:10:18 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20topup=20setup=20=E2=80=94=20omit=20?= =?UTF-8?q?empty=20SAFE/RESOURCE=5FSERVER,=20clearer=20funding=20guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty optionals are dropped from the bridge env (an empty string fails the bridge's validation; RESOURCE_SERVER_URL is optional now). Guidance covers both direct-funded delegate and Safe-allowance options. --- internal/topup/setup.go | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/internal/topup/setup.go b/internal/topup/setup.go index 1560d99..c8268d5 100644 --- a/internal/topup/setup.go +++ b/internal/topup/setup.go @@ -119,19 +119,29 @@ func Setup(o Options) error { args = a } } + env := map[string]any{ + "DELEGATE_PRIVATE_KEY": reuse("DELEGATE_PRIVATE_KEY", delegateKeychainURI), + "SAFE_ADDRESS": safe, + "ALLOWANCE_MODULE_ADDRESS": reuse("ALLOWANCE_MODULE_ADDRESS", defaultAllowanceModule), + "USDCE_ADDRESS": 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": map[string]any{ - "DELEGATE_PRIVATE_KEY": reuse("DELEGATE_PRIVATE_KEY", delegateKeychainURI), - "SAFE_ADDRESS": safe, - "ALLOWANCE_MODULE_ADDRESS": reuse("ALLOWANCE_MODULE_ADDRESS", defaultAllowanceModule), - "USDCE_ADDRESS": 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"), - }, + "env": env, } servers["hpp-x402"] = srv @@ -192,17 +202,19 @@ func printSummary(host string, env map[string]any, safe, resourceServer string, fmt.Println("\nNext steps:") n := 1 if dk, _ := env["DELEGATE_PRIVATE_KEY"].(string); dk == delegateKeychainURI { - fmt.Printf(" %d. Create the payment wallet key:\n", n) + fmt.Printf(" %d. Create the payment wallet key (prints the delegate ADDRESS — you need it next):\n", n) fmt.Printf(" npx -y -p %s hpp-x402-keychain generate\n", bridgePackage) n++ } if safe == "" { - fmt.Printf(" %d. Set up a Safe (https://safe.hpp.io), fund it, authorize the delegate,\n", n) - fmt.Printf(" then re-run with --safe
.\n") + fmt.Printf(" %d. Fund payments — either:\n", n) + fmt.Printf(" a) send USDC.e directly to the delegate address (simplest), or\n") + fmt.Printf(" b) set up a Safe (https://safe.hpp.io), authorize the delegate with a\n") + fmt.Printf(" daily allowance, fund it, then re-run with --safe
.\n") n++ } if resourceServer == "" { - fmt.Println(" ! --resource-server is required for the bridge to start (set and re-run).") + 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) } From 995784d64f6874110b207dc9853086b3f6c2f497 Mon Sep 17 00:00:00 2001 From: "eul.noh" Date: Tue, 9 Jun 2026 14:59:58 +0900 Subject: [PATCH 3/5] feat: topup setup orchestrates delegate key + add 'topup status' verify setup now ensures the delegate payment key (generates it in the OS keychain via the bridge tool when missing) and surfaces its address for funding, instead of leaving it to manual steps. Adds 'hpphub topup status' to verify the wiring: login, delegate key, Hub credit endpoint, on-chain delegate balance, and gateway health. --- cmd/hpphub/main.go | 15 +++- internal/topup/keychain.go | 52 ++++++++++++ internal/topup/setup.go | 23 +++-- internal/topup/status.go | 166 +++++++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 8 deletions(-) create mode 100644 internal/topup/keychain.go create mode 100644 internal/topup/status.go diff --git a/cmd/hpphub/main.go b/cmd/hpphub/main.go index c9f784b..7e19789 100644 --- a/cmd/hpphub/main.go +++ b/cmd/hpphub/main.go @@ -593,7 +593,20 @@ func topupCmd() *cobra.Command { Use: "topup ", Short: "Manage x402 credit auto-top-up", } - cmd.AddCommand(topupSetupCmd()) + 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 } diff --git a/internal/topup/keychain.go b/internal/topup/keychain.go new file mode 100644 index 0000000..05a07b9 --- /dev/null +++ b/internal/topup/keychain.go @@ -0,0 +1,52 @@ +package topup + +import ( + "context" + "fmt" + "os/exec" + "regexp" + "time" +) + +const delegateAccount = "delegate-default" + +var addrRe = regexp.MustCompile(`0x[0-9a-fA-F]{40}`) + +// runBridgeBin runs one of the bridge's CLI bins via npx and returns combined +// output. Best-effort: callers degrade to guidance if npx/network is absent. +func runBridgeBin(args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + full := append([]string{"-y", "-p", bridgePackage}, args...) + out, err := exec.CommandContext(ctx, "npx", full...).CombinedOutput() + return string(out), err +} + +// ensureDelegateAddress returns the delegate EOA address. When generate is +// true it creates the keychain entry if missing (the key never leaves the OS +// keychain — only the address is returned). When false it only reads. +func ensureDelegateAddress(generate bool) (string, error) { + if out, err := runBridgeBin("hpp-x402-keychain", "show", delegateAccount); err == nil { + if a := addrRe.FindString(out); a != "" { + return a, nil + } + } + if !generate { + return "", fmt.Errorf("delegate key not set") + } + 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 from keychain output") +} + +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 index c8268d5..395feb9 100644 --- a/internal/topup/setup.go +++ b/internal/topup/setup.go @@ -177,7 +177,12 @@ func Setup(o Options) error { } } - printSummary(host, srv["env"].(map[string]any), safe, resourceServer, o.EnableAuto) + // Ensure the delegate payment key exists (best-effort: generates it in the + // OS keychain via the bridge tool if missing) and surface the address — + // the user needs it to fund/register on a Safe. + delegateAddr, _ := ensureDelegateAddress(true) + + printSummary(host, safe, resourceServer, delegateAddr, o.EnableAuto) return nil } @@ -192,31 +197,35 @@ func heartbeatTask(hub string, watermark, target float64) string { `, base, watermark, base, target, watermark) } -func printSummary(host string, env map[string]any, safe, resourceServer string, auto bool) { +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 dk, _ := env["DELEGATE_PRIVATE_KEY"].(string); dk == delegateKeychainURI { - fmt.Printf(" %d. Create the payment wallet key (prints the delegate ADDRESS — you need it next):\n", n) + 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 (simplest), or\n") - fmt.Printf(" b) set up a Safe (https://safe.hpp.io), authorize the delegate with a\n") - fmt.Printf(" daily allowance, fund it, then re-run with --safe
.\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 ---- diff --git a/internal/topup/status.go b/internal/topup/status.go new file mode 100644 index 0000000..0448bb4 --- /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, kerr := ensureDelegateAddress(false) + if kerr != nil { + fmt.Println(" ✗ delegate key: not set — run: npx -y -p " + bridgePackage + " hpp-x402-keychain generate") + } 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 +} From 6f3b67a43d67c933226da3296500597ca51b8719 Mon Sep 17 00:00:00 2001 From: "eul.noh" Date: Tue, 9 Jun 2026 15:06:38 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20topup=20setup=20=E2=80=94=20keep/im?= =?UTF-8?q?port=20existing=20delegate=20key=20instead=20of=20always=20gene?= =?UTF-8?q?rating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a delegate key already exists, keep it (Enter) or paste a different 0x key to replace; if none, generate (Enter) or paste a key to import. Non-interactive (piped/CI) keeps existing, else generates. Key is entered via prompt/stdin, never argv. --- internal/topup/keychain.go | 99 +++++++++++++++++++++++++++++++------- internal/topup/setup.go | 8 +-- internal/topup/status.go | 6 +-- 3 files changed, 89 insertions(+), 24 deletions(-) diff --git a/internal/topup/keychain.go b/internal/topup/keychain.go index 05a07b9..879fb21 100644 --- a/internal/topup/keychain.go +++ b/internal/topup/keychain.go @@ -1,39 +1,50 @@ 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}`) +var ( + addrRe = regexp.MustCompile(`0x[0-9a-fA-F]{40}`) + hex64Re = regexp.MustCompile(`^0x[0-9a-fA-F]{64}$`) +) -// runBridgeBin runs one of the bridge's CLI bins via npx and returns combined -// output. Best-effort: callers degrade to guidance if npx/network is absent. +// 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() - full := append([]string{"-y", "-p", bridgePackage}, args...) - out, err := exec.CommandContext(ctx, "npx", full...).CombinedOutput() + 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 } -// ensureDelegateAddress returns the delegate EOA address. When generate is -// true it creates the keychain entry if missing (the key never leaves the OS -// keychain — only the address is returned). When false it only reads. -func ensureDelegateAddress(generate bool) (string, error) { - if out, err := runBridgeBin("hpp-x402-keychain", "show", delegateAccount); err == nil { - if a := addrRe.FindString(out); a != "" { - return a, nil - } - } - if !generate { - return "", fmt.Errorf("delegate key not set") +// 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)) @@ -41,7 +52,61 @@ func ensureDelegateAddress(generate bool) (string, error) { if a := addrRe.FindString(out); a != "" { return a, nil } - return "", fmt.Errorf("could not parse delegate address from keychain output") + 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 { diff --git a/internal/topup/setup.go b/internal/topup/setup.go index 395feb9..c86a8e6 100644 --- a/internal/topup/setup.go +++ b/internal/topup/setup.go @@ -177,10 +177,10 @@ func Setup(o Options) error { } } - // Ensure the delegate payment key exists (best-effort: generates it in the - // OS keychain via the bridge tool if missing) and surface the address — - // the user needs it to fund/register on a Safe. - delegateAddr, _ := ensureDelegateAddress(true) + // 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 diff --git a/internal/topup/status.go b/internal/topup/status.go index 0448bb4..bc1e30a 100644 --- a/internal/topup/status.go +++ b/internal/topup/status.go @@ -50,9 +50,9 @@ func Status(hubOverride string) error { } // delegate key - addr, kerr := ensureDelegateAddress(false) - if kerr != nil { - fmt.Println(" ✗ delegate key: not set — run: npx -y -p " + bridgePackage + " hpp-x402-keychain generate") + addr := keychainShow() + if addr == "" { + fmt.Println(" ✗ delegate key: not set — run 'hpphub topup setup'") } else { fmt.Printf(" ✓ delegate key: %s\n", addr) } From b0b3e9c9a73345f24cd5682abb7bc0212c0dd53c Mon Sep 17 00:00:00 2001 From: "eul.noh" Date: Tue, 9 Jun 2026 15:52:23 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20topup=20setup=20=E2=80=94=20add=20-?= =?UTF-8?q?-usdce=20/=20--allowance-module=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All network params (rpc/network/usdce/allowance) are now flag-overridable, so non-Sepolia networks work without code changes. Defaults stay HPP Sepolia; named mainnet preset is a follow-up (mainnet not yet deployed). --- cmd/hpphub/main.go | 2 ++ internal/topup/setup.go | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/hpphub/main.go b/cmd/hpphub/main.go index 7e19789..824ab24 100644 --- a/cmd/hpphub/main.go +++ b/cmd/hpphub/main.go @@ -625,6 +625,8 @@ func topupSetupCmd() *cobra.Command { 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") diff --git a/internal/topup/setup.go b/internal/topup/setup.go index c86a8e6..193bf0a 100644 --- a/internal/topup/setup.go +++ b/internal/topup/setup.go @@ -40,6 +40,8 @@ type Options struct { ResourceServer string RPC string Network string + USDCe string + AllowanceMod string EnableAuto bool Watermark float64 Target float64 @@ -122,8 +124,8 @@ func Setup(o Options) error { env := map[string]any{ "DELEGATE_PRIVATE_KEY": reuse("DELEGATE_PRIVATE_KEY", delegateKeychainURI), "SAFE_ADDRESS": safe, - "ALLOWANCE_MODULE_ADDRESS": reuse("ALLOWANCE_MODULE_ADDRESS", defaultAllowanceModule), - "USDCE_ADDRESS": reuse("USDCE_ADDRESS", defaultUSDCe), + "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),