diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..ac38648c --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,58 @@ +# Obol CLI — Agent Context + +Machine-readable context for AI agents consuming the `obol` CLI. + +## Structured Output + +- Use `--output json` (or `-o json`, or `OBOL_OUTPUT=json`) for machine-readable output +- JSON goes to stdout; diagnostics go to stderr +- Use `--quiet` to suppress all non-error diagnostics +- Combine: `obol sell list -o json -q` for clean JSON only + +## Non-Interactive Mode + +- All prompts auto-resolve to defaults when stdin is not a TTY +- Use `--force` for destructive operations without confirmation +- Provide all required flags explicitly — don't rely on interactive prompts +- In JSON mode, prompts are never shown + +## Input Requirements + +- Service/resource names: lowercase alphanumeric + hyphens, 1-63 chars +- Wallet addresses: 0x-prefixed, 42 hex chars (EIP-55 checksummed) +- Chain names: `base-sepolia`, `base`, `ethereum` (not CAIP-2 format) +- Prices: positive decimal strings (e.g. `"0.001"`) +- Registration chains accept comma-separated values: `--chain mainnet,base` + +## Commands with JSON Support + +| Command | JSON Output | Notes | +|---------|------------|-------| +| `obol sell list -o json` | ServiceOffer CRs | | +| `obol sell status -o json` | Payment config + routes + registrations | | +| `obol sell status -o json` | Single ServiceOffer CR | | +| `obol sell info -o json` | Inference gateway details | | +| `obol network list -o json` | RPC networks + local nodes | | +| `obol model status -o json` | LLM provider status | | +| `obol model list -o json` | Available models | | +| `obol openclaw list -o json` | OpenClaw instances | | +| `obol tunnel status -o json` | Tunnel mode, status, URL | | +| `obol version -o json` | Version, commit, build time | | +| `obol update -o json` | Available chart updates | | + +## Sell Commands + +- `obol sell inference ` — start x402 payment-gated inference gateway +- `obol sell http ` — publish x402 payment-gated HTTP service +- `obol sell register --chain mainnet,base` — register on ERC-8004 Agent Registry (multi-chain) +- `obol sell pricing --chain base-sepolia` — configure x402 payment wallet/chain +- `obol sell list` / `status` / `stop` / `delete` — manage service offerings + +Wallet is auto-discovered from the remote-signer when available. Override with `--wallet`. + +## Prerequisites + +- Cluster commands require a running stack (`obol stack up`) +- Wallet auto-discovery requires `obol agent init` (creates remote-signer) +- ERC-8004 registration uses the remote-signer for signing (port-forward on demand) +- Sponsored (zero-gas) registration available on ethereum mainnet via `--sponsored` diff --git a/cmd/obol/main.go b/cmd/obol/main.go index e8d80385..47ed42ca 100644 --- a/cmd/obol/main.go +++ b/cmd/obol/main.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime/debug" "syscall" "github.com/ObolNetwork/obol-stack/internal/agent" @@ -64,14 +65,14 @@ COMMANDS: model status Show LiteLLM gateway provider status Sell Services (x402): - sell inference Sell LLM inference via local x402 payment gateway - sell http Sell access to any HTTP service (cluster-based) - sell list List all ServiceOffer CRs - sell status Show offer status or global pricing config - sell stop Stop serving a ServiceOffer - sell delete Delete a ServiceOffer CR - sell pricing Configure x402 pricing in the cluster - sell register Register on ERC-8004 Identity Registry + sell inference Sell local model inference with x402 payments + sell http Sell any local HTTP service with x402 payments + sell list List all services for sale + sell status Show the status of all services for sale + sell stop Stop selling a service + sell delete Delete the sale of a service entirely + sell pricing Manage service pricing + sell register Register on the ERC-8004 Agent Registry (multi-chain) App Management: app install Install a Helm chart as an application @@ -119,9 +120,20 @@ GLOBAL OPTIONS:{{template "visibleFlagTemplate" .}}{{end}} Usage: "Suppress all output except errors and warnings", Sources: cli.EnvVars("OBOL_QUIET"), }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Output format: human or json", + Value: "human", + Sources: cli.EnvVars("OBOL_OUTPUT"), + }, }, Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { - u := ui.NewWithOptions(cmd.Bool("verbose"), cmd.Bool("quiet")) + outputMode, err := ui.ParseOutputMode(cmd.String("output")) + if err != nil { + return ctx, err + } + u := ui.NewWithAllOptions(cmd.Bool("verbose"), cmd.Bool("quiet"), outputMode) cmd.Metadata = map[string]any{"ui": u} return ctx, nil }, @@ -467,6 +479,25 @@ GLOBAL OPTIONS:{{template "visibleFlagTemplate" .}}{{end}} Name: "version", Usage: "Show detailed version information", Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + if u.IsJSON() { + result := struct { + Version string `json:"version"` + GitCommit string `json:"git_commit"` + BuildTime string `json:"build_time"` + GitDirty string `json:"git_dirty"` + GoVersion string `json:"go_version,omitempty"` + }{ + Version: version.Version, + GitCommit: version.GitCommit, + BuildTime: version.BuildTime, + GitDirty: version.GitDirty, + } + if bi, ok := debugReadBuildInfo(); ok { + result.GoVersion = bi + } + return u.JSON(result) + } // Version output should always be unformatted for parseability. fmt.Print(version.BuildInfo()) return nil @@ -614,3 +645,12 @@ func getUI(cmd *cli.Command) *ui.UI { } return ui.New(false) } + +// debugReadBuildInfo returns the Go version from runtime/debug.ReadBuildInfo. +func debugReadBuildInfo() (string, bool) { + bi, ok := debug.ReadBuildInfo() + if !ok { + return "", false + } + return bi.GoVersion, true +} diff --git a/cmd/obol/model.go b/cmd/obol/model.go index f1c335df..3ec644ce 100644 --- a/cmd/obol/model.go +++ b/cmd/obol/model.go @@ -225,6 +225,19 @@ func modelSetupCustomCommand(cfg *config.Config) *cli.Command { } } +// modelStatusResult is the JSON-serialisable result for `model status`. +type modelStatusResult struct { + Providers []modelStatusProvider `json:"providers"` +} + +type modelStatusProvider struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` // "set", "missing", or "n/a" + Models []string `json:"models"` + EnvVar string `json:"env_var,omitempty"` +} + func modelStatusCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "status", @@ -242,6 +255,29 @@ func modelStatusCommand(cfg *config.Config) *cli.Command { } sort.Strings(providers) + if u.IsJSON() { + result := modelStatusResult{} + for _, name := range providers { + s := status[name] + key := "n/a" + if s.EnvVar != "" { + if s.HasAPIKey { + key = "set" + } else { + key = "missing" + } + } + result.Providers = append(result.Providers, modelStatusProvider{ + Name: name, + Enabled: s.Enabled, + APIKey: key, + Models: s.Models, + EnvVar: s.EnvVar, + }) + } + return u.JSON(result) + } + u.Bold("LiteLLM gateway providers:") u.Blank() u.Printf(" %-20s %-8s %-10s %-10s %s", "PROVIDER", "ENABLED", "API KEY", "MODELS", "ENV VAR") @@ -296,11 +332,61 @@ func modelPullCommand() *cli.Command { } } +// modelListResult is the JSON-serialisable result for `model list`. +type modelListResult struct { + Local []modelListLocal `json:"local"` + Gateway []modelListGateway `json:"gateway,omitempty"` +} + +type modelListLocal struct { + Name string `json:"name"` + Size int64 `json:"size"` +} + +type modelListGateway struct { + Provider string `json:"provider"` + Enabled bool `json:"enabled"` + Models []string `json:"models"` +} + func modelListCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "list", Usage: "List pulled Ollama models and cloud provider status", Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + + if u.IsJSON() { + result := modelListResult{} + + if models, err := model.ListOllamaModels(); err == nil { + for _, m := range models { + result.Local = append(result.Local, modelListLocal{ + Name: m.Name, + Size: m.Size, + }) + } + } + + if providerStatus, err := model.GetProviderStatus(cfg); err == nil { + providers := make([]string, 0, len(providerStatus)) + for name := range providerStatus { + providers = append(providers, name) + } + sort.Strings(providers) + for _, name := range providers { + s := providerStatus[name] + result.Gateway = append(result.Gateway, modelListGateway{ + Provider: name, + Enabled: s.Enabled, + Models: s.Models, + }) + } + } + + return u.JSON(result) + } + // List local Ollama models models, err := model.ListOllamaModels() if err != nil { diff --git a/cmd/obol/network.go b/cmd/obol/network.go index cf6bfb49..4a20cf4b 100644 --- a/cmd/obol/network.go +++ b/cmd/obol/network.go @@ -183,14 +183,54 @@ func buildNetworkInstallCommands(cfg *config.Config) []*cli.Command { // network list — unified local nodes + remote RPCs // --------------------------------------------------------------------------- +// networkListResult is the JSON-serialisable result for `network list`. +type networkListResult struct { + LocalNodes []string `json:"local_nodes"` + RPCs []networkListRPCEntry `json:"rpcs"` +} + +type networkListRPCEntry struct { + Alias string `json:"alias"` + ChainID int `json:"chain_id"` + Upstreams int `json:"upstreams"` +} + func networkListCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "list", Usage: "List all networks (local nodes + remote RPCs)", Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + + if u.IsJSON() { + result := networkListResult{} + + // Collect local nodes (best-effort). + if nodes, err := embed.GetAvailableNetworks(); err == nil { + result.LocalNodes = nodes + } + + // Collect remote RPCs. + if rpcNetworks, err := network.ListRPCNetworks(cfg); err == nil { + for _, net := range rpcNetworks { + alias := net.Alias + if alias == "" { + alias = fmt.Sprintf("chain-%d", net.ChainID) + } + result.RPCs = append(result.RPCs, networkListRPCEntry{ + Alias: alias, + ChainID: net.ChainID, + Upstreams: len(net.Upstreams), + }) + } + } + + return u.JSON(result) + } + // Show local node deployments. fmt.Println("Local Nodes:") - if err := network.List(cfg, getUI(cmd)); err != nil { + if err := network.List(cfg, u); err != nil { fmt.Printf(" (unable to list: %v)\n", err) } diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 36978a82..6ba3fecf 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -1,27 +1,34 @@ package main import ( + "bufio" "context" "encoding/hex" "encoding/json" "fmt" + "io" "net" "os" + "os/exec" "os/signal" + "path/filepath" "runtime" "strconv" "strings" "syscall" + "time" "github.com/ObolNetwork/obol-stack/internal/config" "github.com/ObolNetwork/obol-stack/internal/enclave" "github.com/ObolNetwork/obol-stack/internal/erc8004" "github.com/ObolNetwork/obol-stack/internal/inference" "github.com/ObolNetwork/obol-stack/internal/kubectl" + "github.com/ObolNetwork/obol-stack/internal/openclaw" "github.com/ObolNetwork/obol-stack/internal/schemas" "github.com/ObolNetwork/obol-stack/internal/stack" "github.com/ObolNetwork/obol-stack/internal/tee" "github.com/ObolNetwork/obol-stack/internal/tunnel" + "github.com/ObolNetwork/obol-stack/internal/ui" x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" "github.com/ethereum/go-ethereum/crypto" "github.com/mark3labs/x402-go" @@ -52,18 +59,18 @@ func sellCommand(cfg *config.Config) *cli.Command { func sellInferenceCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "inference", - Usage: "Sell LLM inference via a local x402 payment gateway", + Usage: "Sell local model inference with x402 payments", ArgsUsage: "", Description: `Starts an x402-gated reverse proxy in front of a local Ollama instance. Buyers pay per-request in USDC to access inference endpoints. Examples: - obol sell inference my-qwen --model qwen3:0.6b --wallet 0x... --price 0.001 + obol sell inference my-qwen --model qwen3.5:4b --wallet 0x... --price 0.001 obol sell inference my-llama --model llama3:8b --wallet 0x... --chain base`, Flags: []cli.Flag{ &cli.StringFlag{ Name: "model", - Usage: "Model name to serve (e.g. qwen3:0.6b)", + Usage: "Model name to serve (e.g. qwen3.5:4b)", }, &cli.StringFlag{ Name: "wallet", @@ -86,7 +93,7 @@ Examples: }, &cli.StringFlag{ Name: "chain", - Usage: "Payment chain (base, base-sepolia, polygon, polygon-amoy, avalanche, avalanche-fuji)", + Usage: "Payment chain (base-sepolia, base, ethereum)", Value: "base-sepolia", }, &cli.StringFlag{ @@ -148,14 +155,34 @@ Examples: }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) name := cmd.Args().First() if name == "" { - return fmt.Errorf("name required: obol sell inference --wallet ") + if u.IsTTY() { + var err error + name, err = u.Input("Service name", "") + if err != nil || name == "" { + return fmt.Errorf("name required: obol sell inference --wallet ") + } + } else { + return fmt.Errorf("name required: obol sell inference --wallet ") + } } wallet := cmd.String("wallet") if wallet == "" { - return fmt.Errorf("wallet required: use --wallet or set X402_WALLET") + if resolved, err := openclaw.ResolveWalletAddress(cfg); err == nil { + wallet = resolved + fmt.Printf("Using wallet from remote-signer: %s\n", wallet) + } else if u.IsTTY() { + var inputErr error + wallet, inputErr = u.Input("Wallet address (USDC recipient)", "") + if inputErr != nil || wallet == "" { + return fmt.Errorf("wallet required: use --wallet or set X402_WALLET") + } + } else { + return fmt.Errorf("wallet required: use --wallet or set X402_WALLET") + } } if err := x402verifier.ValidateWallet(wallet); err != nil { return err @@ -288,26 +315,24 @@ Examples: func sellHTTPCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "http", - Usage: "Sell access to any HTTP service via x402 (cluster-based)", + Usage: "Sell any local HTTP service with x402 payments", ArgsUsage: "", - Description: `Creates a ServiceOffer in the cluster. The agent reconciles it through: -health-check → payment gate → route publishing → optional ERC-8004 registration. + Description: `Publishes a payment gated HTTP API to any service within the stack, along with a SKILL.md detailing how to use it. +Include --register to have the service listed on EIP8004 onchain agent registry. -Examples: - obol sell http my-api --upstream my-svc --port 8080 --wallet 0x... --price 0.01 - obol sell http my-db-proxy --upstream pgbouncer --port 5432 --wallet 0x... --chain base`, +Example: + obol sell http my-cool-api --upstream my-svc.my-namespace.svc.cluster.local --port 8080 --wallet 0x... --price 0.01 --chain base --register`, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "wallet", - Aliases: []string{"w"}, - Usage: "USDC recipient wallet address", - Sources: cli.EnvVars("X402_WALLET"), - Required: true, + Name: "wallet", + Aliases: []string{"w"}, + Usage: "USDC recipient wallet address (auto-detected from remote-signer)", + Sources: cli.EnvVars("X402_WALLET"), }, &cli.StringFlag{ - Name: "chain", - Usage: "Payment chain (e.g. base-sepolia, base)", - Required: true, + Name: "chain", + Usage: "Payment chain (base-sepolia, base, ethereum)", + Value: "base-sepolia", }, &cli.StringFlag{ Name: "price", @@ -381,10 +406,40 @@ Examples: }, }, Action: func(ctx context.Context, cmd *cli.Command) error { - if cmd.NArg() == 0 { - return fmt.Errorf("name required: obol sell http --wallet --chain ") - } + u := getUI(cmd) name := cmd.Args().First() + if name == "" { + if u.IsTTY() { + var err error + name, err = u.Input("Service name", "") + if err != nil || name == "" { + return fmt.Errorf("name required: obol sell http --wallet --chain ") + } + } else { + return fmt.Errorf("name required: obol sell http --wallet --chain ") + } + } + + // Auto-discover wallet from remote-signer if not set. + wallet := cmd.String("wallet") + if wallet == "" { + if resolved, err := openclaw.ResolveWalletAddress(cfg); err == nil { + wallet = resolved + fmt.Printf("Using wallet from remote-signer: %s\n", wallet) + } else if u.IsTTY() { + var inputErr error + wallet, inputErr = u.Input("Wallet address (USDC recipient)", "") + if inputErr != nil || wallet == "" { + return fmt.Errorf("wallet required: use --wallet or set X402_WALLET") + } + } else { + return fmt.Errorf("wallet required: use --wallet or set X402_WALLET") + } + } + if err := x402verifier.ValidateWallet(wallet); err != nil { + return err + } + ns := cmd.String("namespace") priceTable, err := resolvePriceTable(cmd, true) @@ -412,7 +467,7 @@ Examples: "payment": map[string]interface{}{ "scheme": "exact", "network": cmd.String("chain"), - "payTo": cmd.String("wallet"), + "payTo": wallet, "maxTimeoutSeconds": cmd.Int("max-timeout"), "price": price, }, @@ -465,7 +520,7 @@ Examples: fmt.Printf("Check status: obol sell status %s -n %s\n", name, ns) // Ensure tunnel is active for public access. - u := getUI(cmd) + u = getUI(cmd) u.Blank() u.Info("Ensuring tunnel is active for public access...") if tunnelURL, err := tunnel.EnsureTunnelForSell(cfg, u); err != nil { @@ -486,7 +541,7 @@ Examples: func sellListCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "list", - Usage: "List all ServiceOffer CRs", + Usage: "List all services for sale", Flags: []cli.Flag{ &cli.StringFlag{ Name: "namespace", @@ -495,12 +550,22 @@ func sellListCommand(cfg *config.Config) *cli.Command { }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) args := []string{"get", "serviceoffers.obol.org"} if ns := cmd.String("namespace"); ns != "" { args = append(args, "-n", ns) } else { args = append(args, "-A") } + if u.IsJSON() { + args = append(args, "-o", "json") + out, err := kubectlOutput(cfg, args...) + if err != nil { + return err + } + fmt.Print(out) + return nil + } args = append(args, "-o", "wide") return kubectlRun(cfg, args...) }, @@ -514,7 +579,7 @@ func sellListCommand(cfg *config.Config) *cli.Command { func sellStatusCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "status", - Usage: "Show offer status (with name) or global pricing config (without name)", + Usage: "Show the status of all services for sale or a specific service by name", ArgsUsage: "[name]", Flags: []cli.Flag{ &cli.StringFlag{ @@ -524,6 +589,8 @@ func sellStatusCommand(cfg *config.Config) *cli.Command { }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + // If a name is provided, show per-offer conditions. if cmd.NArg() > 0 { name := cmd.Args().First() @@ -531,15 +598,24 @@ func sellStatusCommand(cfg *config.Config) *cli.Command { if ns == "" { return fmt.Errorf("namespace required: obol sell status -n ") } - return kubectlRun(cfg, "get", "serviceoffers.obol.org", name, "-n", ns, "-o", "yaml") + outputFmt := "-o" + outputVal := "yaml" + if u.IsJSON() { + outputVal = "json" + } + return kubectlRun(cfg, "get", "serviceoffers.obol.org", name, "-n", ns, outputFmt, outputVal) } // No name: show global pricing config + registrations. + if u.IsJSON() { + return sellStatusGlobalJSON(cfg, u) + } + pricingCfg, err := x402verifier.GetPricingConfig(cfg) if err != nil { - fmt.Printf("Cluster pricing: not available (%v)\n", err) + fmt.Printf("Payment configuration not available (%v)\n", err) } else { - fmt.Printf("x402 Cluster Configuration:\n") + fmt.Printf("Payment Configuration:\n") fmt.Printf(" Wallet: %s\n", valueOrNone(pricingCfg.Wallet)) fmt.Printf(" Chain: %s\n", valueOrNone(pricingCfg.Chain)) fmt.Printf(" Facilitator: %s\n", valueOrNone(pricingCfg.FacilitatorURL)) @@ -560,7 +636,7 @@ func sellStatusCommand(cfg *config.Config) *cli.Command { fmt.Println() - fmt.Printf("ERC-8004 Registration:\n") + fmt.Printf("ERC-8004 Agent Registration:\n") kubectlRun(cfg, "get", "serviceoffers.obol.org", "-A", "-o", "custom-columns=NAMESPACE:.metadata.namespace,NAME:.metadata.name,AGENT_ID:.status.agentId,TX:.status.registrationTxHash,REGISTERED:.status.conditions[?(@.type=='Registered')].status") @@ -580,6 +656,91 @@ func sellStatusCommand(cfg *config.Config) *cli.Command { } } +// sellStatusGlobalJSON outputs the global sell status as JSON. +func sellStatusGlobalJSON(cfg *config.Config, u *ui.UI) error { + type routeJSON struct { + Pattern string `json:"pattern"` + Price string `json:"price"` + Description string `json:"description,omitempty"` + PayTo string `json:"pay_to,omitempty"` + PriceModel string `json:"price_model,omitempty"` + PerMTok string `json:"per_mtok,omitempty"` + ApproxTokensPerRequest int `json:"approx_tokens_per_request,omitempty"` + } + type gatewayJSON struct { + Name string `json:"name"` + ListenAddr string `json:"listen_addr"` + UpstreamURL string `json:"upstream_url"` + Price string `json:"price"` + Chain string `json:"chain"` + } + type statusGlobal struct { + Payment *struct { + Wallet string `json:"wallet"` + Chain string `json:"chain"` + FacilitatorURL string `json:"facilitator_url"` + VerifyOnly bool `json:"verify_only"` + Routes []routeJSON `json:"routes"` + } `json:"payment,omitempty"` + PaymentError string `json:"payment_error,omitempty"` + Registrations json.RawMessage `json:"registrations,omitempty"` + LocalGateways []gatewayJSON `json:"local_gateways,omitempty"` + } + + var result statusGlobal + + pricingCfg, err := x402verifier.GetPricingConfig(cfg) + if err != nil { + result.PaymentError = err.Error() + } else { + p := &struct { + Wallet string `json:"wallet"` + Chain string `json:"chain"` + FacilitatorURL string `json:"facilitator_url"` + VerifyOnly bool `json:"verify_only"` + Routes []routeJSON `json:"routes"` + }{ + Wallet: pricingCfg.Wallet, + Chain: pricingCfg.Chain, + FacilitatorURL: pricingCfg.FacilitatorURL, + VerifyOnly: pricingCfg.VerifyOnly, + } + for _, r := range pricingCfg.Routes { + p.Routes = append(p.Routes, routeJSON{ + Pattern: r.Pattern, + Price: r.Price, + Description: r.Description, + PayTo: r.PayTo, + PriceModel: r.PriceModel, + PerMTok: r.PerMTok, + ApproxTokensPerRequest: r.ApproxTokensPerRequest, + }) + } + result.Payment = p + } + + // Fetch registrations as raw JSON from kubectl. + regOut, regErr := kubectlOutput(cfg, "get", "serviceoffers.obol.org", "-A", "-o", "json") + if regErr == nil { + result.Registrations = json.RawMessage(regOut) + } + + // Local inference gateways. + store := inference.NewStore(cfg.ConfigDir) + deployments, _ := store.List() + for _, d := range deployments { + result.LocalGateways = append(result.LocalGateways, gatewayJSON{ + Name: d.Name, + ListenAddr: d.ListenAddr, + UpstreamURL: d.UpstreamURL, + Price: formatInferencePriceSummary(d), + Chain: d.Chain, + }) + } + + return u.JSON(result) +} + // --------------------------------------------------------------------------- // sell stop // --------------------------------------------------------------------------- @@ -587,7 +748,7 @@ func sellStatusCommand(cfg *config.Config) *cli.Command { func sellStopCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "stop", - Usage: "Stop serving a ServiceOffer (removes pricing route, keeps CR)", + Usage: "Stop selling a service", ArgsUsage: "", Flags: []cli.Flag{ &cli.StringFlag{ @@ -604,7 +765,7 @@ func sellStopCommand(cfg *config.Config) *cli.Command { name := cmd.Args().First() ns := cmd.String("namespace") - fmt.Printf("Stopping ServiceOffer %s/%s...\n", ns, name) + fmt.Printf("Stopping the service offering %s/%s...\n", ns, name) removePricingRoute(cfg, name) @@ -615,7 +776,7 @@ func sellStopCommand(cfg *config.Config) *cli.Command { return fmt.Errorf("failed to patch status: %w", err) } - fmt.Printf("ServiceOffer %s/%s stopped.\n", ns, name) + fmt.Printf("Service offering %s/%s stopped.\n", ns, name) return nil }, } @@ -628,7 +789,7 @@ func sellStopCommand(cfg *config.Config) *cli.Command { func sellDeleteCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "delete", - Usage: "Delete a ServiceOffer CR and deactivate ERC-8004 registration", + Usage: "Delete the sale of a service entirely and deactivate its ERC-8004 agent registration", ArgsUsage: "", Flags: []cli.Flag{ &cli.StringFlag{ @@ -644,6 +805,7 @@ func sellDeleteCommand(cfg *config.Config) *cli.Command { }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) if cmd.NArg() == 0 { return fmt.Errorf("name required: obol sell delete -n ") } @@ -651,14 +813,8 @@ func sellDeleteCommand(cfg *config.Config) *cli.Command { ns := cmd.String("namespace") if !cmd.Bool("force") { - fmt.Printf("Delete ServiceOffer %s/%s? This will:\n", ns, name) - fmt.Println(" - Remove the associated Middleware and HTTPRoute") - fmt.Println(" - Remove the pricing route from the x402 verifier") - fmt.Println(" - Deactivate the ERC-8004 registration (if registered)") - fmt.Print("[y/N] ") - var response string - fmt.Scanln(&response) - if !strings.EqualFold(response, "y") && !strings.EqualFold(response, "yes") { + msg := fmt.Sprintf("Delete the service offering %s/%s? This will:\n - Remove the associated Middleware and HTTPRoute\n - Remove the pricing route from the x402 verifier\n - Deactivate the ERC-8004 registration (if registered)", ns, name) + if !u.Confirm(msg, false) { fmt.Println("Aborted.") return nil } @@ -690,7 +846,7 @@ func sellDeleteCommand(cfg *config.Config) *cli.Command { }) if patchErr := kubectlRun(cfg, "patch", "configmap", cmName, "-n", ns, "-p", string(patchJSON), "--type=merge"); patchErr != nil { - fmt.Printf(" Warning: could not deactivate registration: %v\n", patchErr) + fmt.Printf(" Warning: could not deactivate agent registration: %v\n", patchErr) } else { fmt.Printf(" Registration deactivated (active=false). On-chain NFT persists.\n") } @@ -727,19 +883,18 @@ func sellDeleteCommand(cfg *config.Config) *cli.Command { func sellPricingCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "pricing", - Usage: "Configure x402 pricing in the cluster", + Usage: "Manage service pricing", Description: `Sets the wallet address and chain for x402 payment collection. -Stakater Reloader auto-restarts the verifier pod on config changes.`, +Reloads the payment verifier when configuration is changed.`, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "wallet", - Usage: "USDC recipient wallet address (EVM)", - Sources: cli.EnvVars("X402_WALLET"), - Required: true, + Name: "wallet", + Usage: "USDC recipient wallet address (auto-detected from remote-signer)", + Sources: cli.EnvVars("X402_WALLET"), }, &cli.StringFlag{ Name: "chain", - Usage: "Payment chain (base, base-sepolia)", + Usage: "Payment chain (base-sepolia, base, ethereum)", Value: "base-sepolia", }, &cli.StringFlag{ @@ -750,6 +905,14 @@ Stakater Reloader auto-restarts the verifier pod on config changes.`, }, Action: func(ctx context.Context, cmd *cli.Command) error { wallet := cmd.String("wallet") + if wallet == "" { + if resolved, err := openclaw.ResolveWalletAddress(cfg); err == nil { + wallet = resolved + fmt.Printf("Using wallet from remote-signer: %s\n", wallet) + } else { + return fmt.Errorf("wallet required: use --wallet or set X402_WALLET") + } + } if err := x402verifier.ValidateWallet(wallet); err != nil { return err } @@ -765,23 +928,25 @@ Stakater Reloader auto-restarts the verifier pod on config changes.`, func sellRegisterCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "register", - Usage: "Register service on ERC-8004 Identity Registry (Base Sepolia)", - Description: `Mints an agent NFT on the ERC-8004 Identity Registry. -Requires a funded Base Sepolia wallet (private key).`, + Usage: "Register a service on the ERC-8004 Agent Registry", + Description: `Registers an agent on the ERC-8004 Agent Registry on one or more chains. +Uses the remote-signer wallet by default. Supports sponsored (zero-gas) +registration on networks that offer it (e.g. ethereum mainnet). + +Examples: + obol sell register # interactive, defaults to base-sepolia + obol sell register --chain base-sepolia # register on base-sepolia + obol sell register --chain mainnet,base # register on multiple chains + obol sell register --chain mainnet --sponsored # zero-gas on ethereum mainnet`, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "private-key", - Usage: "DEPRECATED: use --private-key-file or ERC8004_PRIVATE_KEY env var", - Sources: cli.EnvVars("ERC8004_PRIVATE_KEY"), - }, - &cli.StringFlag{ - Name: "private-key-file", - Usage: "Path to file containing secp256k1 private key (hex)", + Name: "chain", + Usage: "Registration chain(s), comma-separated (base-sepolia, base, mainnet)", + Value: "base-sepolia", }, - &cli.StringFlag{ - Name: "rpc-url", - Usage: "Base Sepolia JSON-RPC URL", - Value: erc8004.DefaultRPCURL, + &cli.BoolFlag{ + Name: "sponsored", + Usage: "Use sponsored (zero-gas) registration when available", }, &cli.StringFlag{ Name: "endpoint", @@ -789,81 +954,259 @@ Requires a funded Base Sepolia wallet (private key).`, }, &cli.StringFlag{ Name: "name", - Usage: "Agent name", - Value: "Obol Stack", + Usage: "Agent name for registration", + Value: "Obol Agent", }, &cli.StringFlag{ Name: "description", Usage: "Agent description", + Value: "Obol Stack AI agent with x402 payment-gated services", + }, + &cli.StringFlag{ + Name: "image", + Usage: "Agent image URL for registration", + }, + &cli.StringFlag{ + Name: "private-key-file", + Usage: "Path to private key file (fallback if no remote-signer available)", + Sources: cli.EnvVars("ERC8004_PRIVATE_KEY"), }, }, Action: func(ctx context.Context, cmd *cli.Command) error { - keyHex := cmd.String("private-key") - if keyHex == "" { - if keyFile := cmd.String("private-key-file"); keyFile != "" { - data, err := os.ReadFile(keyFile) - if err != nil { - return fmt.Errorf("read private key file: %w", err) + u := getUI(cmd) + + // Resolve networks. + chainCSV := cmd.String("chain") + if u.IsTTY() && !cmd.IsSet("chain") { + nets := erc8004.SupportedNetworks() + options := make([]string, len(nets)) + for i, n := range nets { + label := n.Name + if n.HasSponsor() { + label += " (sponsored, zero gas)" } - keyHex = strings.TrimSpace(string(data)) + options[i] = label } + idx, err := u.Select("Registration network", options, 0) + if err != nil { + return err + } + chainCSV = nets[idx].Name } - if keyHex == "" { - return fmt.Errorf("private key required: use --private-key-file or set ERC8004_PRIVATE_KEY") - } - if cmd.IsSet("private-key") { - fmt.Fprintf(os.Stderr, "Warning: --private-key flag exposes key in process args. Use --private-key-file or ERC8004_PRIVATE_KEY env var instead.\n") - } - keyHex = strings.TrimPrefix(keyHex, "0x") - key, err := crypto.HexToECDSA(keyHex) + networks, err := erc8004.ResolveNetworks(chainCSV) if err != nil { - return fmt.Errorf("invalid private key: %w", err) + return err + } + + // Interactive confirmation of registration metadata. + agentName := cmd.String("name") + agentDesc := cmd.String("description") + if u.IsTTY() { + if !cmd.IsSet("name") { + if val, err := u.Input("Agent name", agentName); err == nil && val != "" { + agentName = val + } + } + if !cmd.IsSet("description") { + if val, err := u.Input("Agent description", agentDesc); err == nil && val != "" { + agentDesc = val + } + } } + // Resolve endpoint. endpoint := cmd.String("endpoint") if endpoint == "" { tunnelURL, err := tunnel.GetTunnelURL(cfg) if err != nil { - return fmt.Errorf("--endpoint required (tunnel auto-detect failed: %v)", err) + if u.IsTTY() { + endpoint, _ = u.Input("Service endpoint URL", "") + } + if endpoint == "" { + return fmt.Errorf("--endpoint required (tunnel auto-detect failed: %v)", err) + } + } else { + endpoint = tunnelURL + fmt.Printf("Auto-detected endpoint from tunnel: %s\n", endpoint) } - endpoint = tunnelURL - fmt.Printf("Auto-detected endpoint from tunnel: %s\n", endpoint) } - agentURI := endpoint + "/.well-known/agent-registration.json" - fmt.Printf("Registering agent on ERC-8004 Identity Registry (Base Sepolia)...\n") - fmt.Printf(" Agent URI: %s\n", agentURI) - fmt.Printf(" Registry: %s\n", erc8004.IdentityRegistryBaseSepolia) - client, err := erc8004.NewClient(ctx, cmd.String("rpc-url")) - if err != nil { - return fmt.Errorf("connect to Base Sepolia: %w", err) + // Determine signing method: remote-signer (preferred) or private key file (fallback). + useRemoteSigner := false + var signerNS string + + if _, err := openclaw.ResolveWalletAddress(cfg); err == nil { + ns, nsErr := openclaw.ResolveInstanceNamespace(cfg) + if nsErr == nil { + useRemoteSigner = true + signerNS = ns + } } - defer client.Close() - agentID, err := client.Register(ctx, key, agentURI) - if err != nil { - return fmt.Errorf("register: %w", err) + // Fallback to private key file if no remote-signer. + var fallbackKey string + if !useRemoteSigner { + keyFile := cmd.String("private-key-file") + if keyFile != "" { + data, err := os.ReadFile(keyFile) + if err != nil { + return fmt.Errorf("read private key file: %w", err) + } + fallbackKey = strings.TrimSpace(string(data)) + } + if fallbackKey == "" { + return fmt.Errorf("no remote-signer wallet found and no --private-key-file provided.\nRun 'obol agent init' first, or use --private-key-file") + } } - txAddr := crypto.PubkeyToAddress(key.PublicKey) - fmt.Printf("\nAgent registered successfully!\n") - fmt.Printf(" Agent ID: %s\n", agentID.String()) - fmt.Printf(" Owner: %s\n", txAddr.Hex()) + // Register on each network (best-effort). + fmt.Printf("Registering agent on ERC-8004 Agent Registry...\n") + fmt.Printf(" Agent URI: %s\n", agentURI) + fmt.Printf(" Networks: %s\n", chainCSV) + + var successes int + for _, net := range networks { + fmt.Printf("\n [%s] (chain ID %d)\n", net.Name, net.ChainID) + fmt.Printf(" Registry: %s\n", net.RegistryAddress) + + sponsored := net.HasSponsor() && (cmd.Bool("sponsored") || !cmd.IsSet("sponsored")) - x402Meta := []byte(`{"x402":true}`) - if err := client.SetMetadata(ctx, key, agentID, "x402", x402Meta); err != nil { - fmt.Printf(" Warning: failed to set x402 metadata: %v\n", err) + if sponsored && useRemoteSigner { + // Sponsored path via remote-signer. + if err := registerSponsored(ctx, cfg, net, agentURI, signerNS); err != nil { + fmt.Printf(" Warning: sponsored registration failed: %v\n", err) + continue + } + } else if useRemoteSigner { + // Direct on-chain via remote-signer (needs funded wallet). + if err := registerDirectViaSigner(ctx, cfg, net, agentURI, signerNS); err != nil { + fmt.Printf(" Warning: direct registration failed: %v\n", err) + continue + } + } else { + // Fallback: direct on-chain with private key file. + if err := registerDirectWithKey(ctx, net, agentURI, fallbackKey); err != nil { + fmt.Printf(" Warning: registration failed: %v\n", err) + continue + } + } + + fmt.Printf(" CAIP-10: %s\n", net.CAIP10Registry()) + successes++ } - fmt.Printf(" Registry: eip155:%d:%s\n", erc8004.BaseSepoliaChainID, erc8004.IdentityRegistryBaseSepolia) + if successes == 0 { + return fmt.Errorf("registration failed on all networks") + } + fmt.Printf("\nAgent registered on %d/%d networks.\n", successes, len(networks)) return nil }, } } +// registerSponsored performs a sponsored (zero-gas) registration via the remote-signer. +func registerSponsored(ctx context.Context, cfg *config.Config, net erc8004.NetworkConfig, agentURI, namespace string) error { + fmt.Printf(" Using sponsored registration (zero gas)...\n") + + // Port-forward to remote-signer. + pf, err := startSignerPortForward(cfg, namespace) + if err != nil { + return fmt.Errorf("port-forward to remote-signer: %w", err) + } + defer pf.Stop() + + signer := erc8004.NewRemoteSigner(fmt.Sprintf("http://localhost:%d", pf.localPort)) + + agentID, txHash, err := erc8004.SponsoredRegister(ctx, signer, agentURI, net) + if err != nil { + return err + } + + fmt.Printf(" Agent ID: %s\n", agentID.String()) + fmt.Printf(" Tx hash: %s\n", txHash) + return nil +} + +// registerDirectViaSigner performs a direct on-chain registration via the remote-signer. +func registerDirectViaSigner(ctx context.Context, cfg *config.Config, net erc8004.NetworkConfig, agentURI, namespace string) error { + fmt.Printf(" Using direct on-chain registration via remote-signer...\n") + + // Port-forward to remote-signer. + pf, err := startSignerPortForward(cfg, namespace) + if err != nil { + return fmt.Errorf("port-forward to remote-signer: %w", err) + } + defer pf.Stop() + + signer := erc8004.NewRemoteSigner(fmt.Sprintf("http://localhost:%d", pf.localPort)) + + addr, err := signer.GetAddress(ctx) + if err != nil { + return err + } + fmt.Printf(" Wallet: %s\n", addr.Hex()) + + // Connect to eRPC for this network. + client, err := erc8004.NewClientForNetwork(ctx, "http://localhost/rpc", net) + if err != nil { + return fmt.Errorf("connect to %s via eRPC: %w", net.Name, err) + } + defer client.Close() + + // Create TransactOpts that delegates signing to the remote-signer. + opts := signer.RemoteTransactOpts(ctx, addr, client.ChainID()) + + agentID, err := client.RegisterWithOpts(ctx, opts, agentURI) + if err != nil { + return err + } + + fmt.Printf(" Agent ID: %s\n", agentID.String()) + fmt.Printf(" Owner: %s\n", addr.Hex()) + + // Set x402 metadata. + x402Meta := []byte(`{"x402":true}`) + if err := client.SetMetadataWithOpts(ctx, opts, agentID, "x402", x402Meta); err != nil { + fmt.Printf(" Warning: failed to set x402 metadata: %v\n", err) + } + return nil +} + +// registerDirectWithKey performs a direct on-chain registration using a raw private key. +func registerDirectWithKey(ctx context.Context, net erc8004.NetworkConfig, agentURI, keyHex string) error { + fmt.Printf(" Using direct on-chain registration with private key...\n") + + keyHex = strings.TrimPrefix(keyHex, "0x") + key, err := crypto.HexToECDSA(keyHex) + if err != nil { + return fmt.Errorf("invalid private key: %w", err) + } + + client, err := erc8004.NewClientForNetwork(ctx, "http://localhost/rpc", net) + if err != nil { + return fmt.Errorf("connect to %s via eRPC: %w", net.Name, err) + } + defer client.Close() + + agentID, err := client.Register(ctx, key, agentURI) + if err != nil { + return err + } + + txAddr := crypto.PubkeyToAddress(key.PublicKey) + fmt.Printf(" Agent ID: %s\n", agentID.String()) + fmt.Printf(" Owner: %s\n", txAddr.Hex()) + + x402Meta := []byte(`{"x402":true}`) + if err := client.SetMetadata(ctx, key, agentID, "x402", x402Meta); err != nil { + fmt.Printf(" Warning: failed to set x402 metadata: %v\n", err) + } + return nil +} + // --------------------------------------------------------------------------- // inference gateway helpers (from service.go) // --------------------------------------------------------------------------- @@ -911,16 +1254,106 @@ func resolveX402Chain(name string) (x402.ChainConfig, error) { return x402.BaseMainnet, nil case "base-sepolia": return x402.BaseSepolia, nil - case "polygon", "polygon-mainnet": - return x402.PolygonMainnet, nil - case "polygon-amoy": - return x402.PolygonAmoy, nil - case "avalanche", "avalanche-mainnet": - return x402.AvalancheMainnet, nil - case "avalanche-fuji": - return x402.AvalancheFuji, nil + case "ethereum", "ethereum-mainnet", "mainnet": + // Ethereum mainnet USDC: verified 2025-10-28 + return x402.ChainConfig{ + NetworkID: "ethereum", + USDCAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + Decimals: 6, + EIP3009Name: "USD Coin", + EIP3009Version: "2", + }, nil default: - return x402.ChainConfig{}, fmt.Errorf("unsupported chain: %s", name) + return x402.ChainConfig{}, fmt.Errorf("unsupported chain: %s (supported: base-sepolia, base, ethereum)", name) + } +} + +// startSignerPortForward launches a temporary port-forward to the remote-signer +// service in the given namespace. Caller must call pf.Stop() when done. +func startSignerPortForward(cfg *config.Config, namespace string) (*signerPortForwarder, error) { + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { + return nil, fmt.Errorf("cluster not running. Run 'obol stack up' first") + } + + kubectlBinary := filepath.Join(cfg.BinDir, "kubectl") + + ctx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(ctx, kubectlBinary, "port-forward", + "svc/remote-signer", ":9000", "-n", namespace) + cmd.Env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeconfigPath)) + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start port-forward: %w", err) + } + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + parsedPort := make(chan int, 1) + parseErr := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stdoutPipe) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "Forwarding from") { + parts := strings.Split(line, ":") + if len(parts) >= 2 { + portPart := strings.Fields(parts[len(parts)-1])[0] + var p int + if _, scanErr := fmt.Sscanf(portPart, "%d", &p); scanErr == nil { + parsedPort <- p + io.Copy(io.Discard, stdoutPipe) + return + } + } + } + } + parseErr <- fmt.Errorf("port-forward exited without reporting a local port") + }() + + select { + case p := <-parsedPort: + return &signerPortForwarder{cmd: cmd, localPort: p, done: done, cancel: cancel}, nil + case err := <-parseErr: + cancel() + return nil, err + case err := <-done: + cancel() + if err != nil { + return nil, fmt.Errorf("port-forward exited: %w", err) + } + return nil, fmt.Errorf("port-forward exited unexpectedly") + case <-time.After(30 * time.Second): + cancel() + return nil, fmt.Errorf("timed out waiting for port-forward") + } +} + +// signerPortForwarder manages a background port-forward to the remote-signer. +type signerPortForwarder struct { + cmd *exec.Cmd + localPort int + done chan error + cancel context.CancelFunc +} + +// Stop terminates the port-forward process. +func (pf *signerPortForwarder) Stop() { + pf.cancel() + select { + case <-pf.done: + case <-time.After(5 * time.Second): + if pf.cmd.Process != nil { + pf.cmd.Process.Kill() + } } } @@ -939,6 +1372,7 @@ func sellInfoCommand(cfg *config.Config) *cli.Command { }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) name := cmd.Args().First() if name == "" { return fmt.Errorf("usage: obol sell info ") @@ -958,7 +1392,7 @@ func sellInfoCommand(cfg *config.Config) *cli.Command { k, keyErr = enclave.NewKey(d.EnclaveTag) } - if cmd.Bool("json") { + if u.IsJSON() || cmd.Bool("json") { out := map[string]any{ "name": d.Name, "enclave_tag": d.EnclaveTag, diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index 4b067ba1..a135010b 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -190,8 +190,7 @@ func TestSellHTTP_Flags(t *testing.T) { "register", "register-name", "register-description", "register-image", ) - assertFlagRequired(t, flags, "wallet") - assertFlagRequired(t, flags, "chain") + assertStringDefault(t, flags, "chain", "base-sepolia") assertStringDefault(t, flags, "namespace", "default") assertStringDefault(t, flags, "health-path", "/health") assertIntDefault(t, flags, "port", 8080) @@ -228,9 +227,13 @@ func TestSellRegister_Flags(t *testing.T) { flags := flagMap(reg) requireFlags(t, flags, - "private-key", "private-key-file", "rpc-url", - "endpoint", "name", "description", + "chain", "sponsored", "private-key-file", + "endpoint", "name", "description", "image", ) + + assertStringDefault(t, flags, "chain", "base-sepolia") + assertStringDefault(t, flags, "name", "Obol Agent") + assertStringDefault(t, flags, "description", "Obol Stack AI agent with x402 payment-gated services") } func TestSellPricing_Flags(t *testing.T) { @@ -240,7 +243,6 @@ func TestSellPricing_Flags(t *testing.T) { flags := flagMap(pricing) requireFlags(t, flags, "wallet", "chain") - assertFlagRequired(t, flags, "wallet") assertStringDefault(t, flags, "chain", "base-sepolia") } @@ -282,12 +284,9 @@ func TestResolveX402Chain(t *testing.T) { {"base", false}, {"base-mainnet", false}, {"base-sepolia", false}, - {"polygon", false}, - {"polygon-mainnet", false}, - {"polygon-amoy", false}, - {"avalanche", false}, - {"avalanche-mainnet", false}, - {"avalanche-fuji", false}, + {"ethereum", false}, + {"mainnet", false}, + {"ethereum-mainnet", false}, {"unknown-chain", true}, } diff --git a/cmd/obol/update.go b/cmd/obol/update.go index 19f585b7..1ec8a51d 100644 --- a/cmd/obol/update.go +++ b/cmd/obol/update.go @@ -31,7 +31,7 @@ func updateCommand(cfg *config.Config) *cli.Command { clusterRunning = false } - jsonMode := cmd.Bool("json") + jsonMode := cmd.Bool("json") || u.IsJSON() if !jsonMode && clusterRunning { u.Info("Updating helm repositories...") diff --git a/docs/plans/cli-agent-readiness.md b/docs/plans/cli-agent-readiness.md new file mode 100644 index 00000000..2c501691 --- /dev/null +++ b/docs/plans/cli-agent-readiness.md @@ -0,0 +1,306 @@ +# CLI Agent-Readiness Optimizations + +## Status + +**Implemented (this branch)**: +- Phase 1: Global `--output json` / `-o json` / `OBOL_OUTPUT=json` flag +- Phase 1: `OutputMode` + `IsJSON()` + `JSON()` on `internal/ui/UI` +- Phase 1: 11 commands refactored with typed JSON results (sell list/status/info, network list, model status/list, version, update, openclaw list, tunnel status) +- Phase 1: Human output redirected to stderr in JSON mode (stdout is clean JSON) +- Phase 2: `internal/validate/` package (Name, Namespace, WalletAddress, ChainName, Price, URL, Path, NoControlChars) +- Phase 2: Headless prompt paths — `Confirm`, `Select`, `Input`, `SecretInput` auto-resolve defaults in non-TTY/JSON mode +- Phase 2: `sell delete` migrated from raw `fmt.Scanln` to `u.Confirm()` +- Phase 6: `CONTEXT.md` — agent-facing context document + +**Deferred to follow-up**: +- Phase 1D: `--from-json` raw JSON input +- Phase 2B: `validate.*` wired into all command handlers +- Phase 2C: model.go bufio migration, openclaw onboard headless path +- Phase 3: `obol describe` schema introspection +- Phase 4: `--fields` field filtering +- Phase 5: `--dry-run` for mutating commands +- Phase 7: MCP surface (`obol mcp`) + +## Context + +The obol CLI is increasingly consumed by AI agents — Claude Code during development, OpenClaw agents in-cluster, and soon MCP clients. Today the CLI is human-optimized: colored output, spinners, interactive prompts, and hand-formatted tables. Agents need structured output, non-interactive paths, input hardening, and runtime introspection. This plan makes the CLI agent-ready while preserving human DX. + +**Strengths**: `internal/ui/` abstraction with TTY detection, `OutputMode` (human/json), `--verbose`/`--quiet`/`--output` global flags, `internal/schemas/` with JSON-tagged Go types, `internal/validate/` for input validation, `--force` pattern for non-interactive destructive ops, 23 SKILL.md files shipped in `internal/embed/skills/`, `CONTEXT.md` for agent consumption. + +**Remaining gaps**: `--from-json` for structured input, some `fmt.Printf` calls still bypass UI layer, `model.go` interactive prompts not fully migrated, `openclaw onboard` still hardwired `Interactive: true`, no schema introspection, no `--dry-run`, no field filtering, no MCP surface. + +--- + +## Phase 1: Global `--output json` + Raw JSON Input + +Structured output is table stakes. Raw JSON input (`--from-json`) is first-class — agents shouldn't have to translate nested structures into 15+ flags. + +### 1A. Extend UI struct with output mode + +**`internal/ui/ui.go`** — Add `OutputMode` type (`human`|`json`) and field to `UI` struct. Add `NewWithAllOptions(verbose, quiet bool, output OutputMode)`. Add `IsJSON() bool`. + +**`internal/ui/output.go`** — Add `JSON(v any) error` method that writes to stdout via `json.NewEncoder`. When `IsJSON()` is true, redirect `Info`/`Success`/`Detail`/`Print`/`Printf`/`Dim`/`Bold`/`Blank` to stderr (so agents get clean JSON on stdout, diagnostics on stderr). Suppress spinners in JSON mode. + +### 1B. Add global `--output` flag + +**`cmd/obol/main.go`** (lines 110-127) — Add `--output` / `-o` flag (`human`|`json`, env `OBOL_OUTPUT`, default `human`). Wire in `Before` hook to pass to `ui.NewWithAllOptions`. + +### 1C. Refactor commands to return typed results + +Don't just bolt JSON onto existing `fmt.Printf` calls. Refactor high-value commands to return typed data first, then format for human or JSON. This pays off twice: clean JSON output now, and reusable typed results for MCP later. + +**Audit note**: Raw `fmt.Printf` output is spread across `main.go:460` (version), `model.go:286` (tables), `network.go:188` (tables), and throughout `sell.go`. Each needs a return-data-then-format refactor. + +| Command | Strategy | Effort | +|---------|----------|--------| +| `sell list` | Switch kubectl arg from `-o wide` to `-o json` | Trivial | +| `sell status ` | Switch kubectl arg from `-o yaml` to `-o json` | Trivial | +| `sell status` (global) | Marshal `PricingConfig` + `store.List()` — currently raw `fmt.Printf` at `sell.go:463-498` | Medium | +| `sell info` | Already has `--json` (`sell.go:841`) — wire to global flag, deprecate local | Trivial | +| `network list` | `ListRPCNetworks()` returns `[]RPCNetworkInfo` — marshal it, but local node output also uses `fmt.Printf` at `network.go:188` | Medium | +| `model status` | Return provider status map as JSON — currently `fmt.Printf` tables at `model.go:286` | Medium | +| `model list` | `ListOllamaModels()` returns structured data | Low | +| `version` | `BuildInfo()` returns a string today — refactor to struct with fields (version, commit, date, go version) | Medium | +| `update` | Already has `--json` (`update.go:20`); wire to global flag, deprecate local | Trivial | +| `openclaw list` | Refactor to return data before formatting | Medium | +| `tunnel status` | Refactor to return data before formatting | Medium | + +### 1D. Raw JSON input (`--from-json`) + +Add `--from-json` flag to all commands that create resources. Accepts file path or `-` for stdin. Unmarshals into existing `internal/schemas/` types, validates, creates manifest. This is first-class, not an afterthought. + +| Command | Schema Type | Flags Bypassed | +|---------|-------------|----------------| +| `sell http` | `schemas.ServiceOfferSpec` | 15+ flags (wallet, chain, price, upstream, port, namespace, health-path, etc.) | +| `sell inference` | `schemas.ServiceOfferSpec` | 10+ flags | +| `sell pricing` | `schemas.PaymentTerms` | wallet, chain, facilitator | +| `network add` | New `RPCConfig` type | endpoint, chain-id, allow-writes | + +### Testing +- `internal/ui/ui_test.go`: OutputMode switching, JSON writes valid JSON to stdout, human methods go to stderr in JSON mode +- `cmd/obol/output_test.go`: `--output json` on each migrated command produces parseable JSON +- `cmd/obol/json_input_test.go`: `--from-json` with valid/invalid specs + +--- + +## Phase 2: Input Validation + Headless Paths + +Agents hallucinate inputs and can't answer interactive prompts. Fix both together. + +### 2A. New validation package + +**`internal/validate/validate.go`** (new) + +``` +Name(s) — k8s-safe: [a-z0-9][a-z0-9.-]*, no path traversal +Namespace(s) — same rules as Name +WalletAddress(s) — reuse x402verifier.ValidateWallet() pattern +ChainName(s) — from known set (base, base-sepolia, etc.) +Path(s) — no .., no %2e%2e, no control chars +Price(s) — valid decimal, positive +URL(s) — parseable, no control chars +NoControlChars(s) — reject \x00-\x1f except \n\t +``` + +### 2B. Wire into commands + +Add validation at the top of every action handler for positional args and key flags: +- **`cmd/obol/sell.go`**: name, wallet, chain, path, price, namespace, upstream URL +- **`cmd/obol/network.go`**: network name, custom RPC URL, chain ID +- **`cmd/obol/model.go`**: provider name, endpoint URL +- **`cmd/obol/openclaw.go`**: instance ID + +### 2C. Headless paths for interactive flows + +**`internal/ui/prompt.go`** — When `IsJSON() || !IsTTY()`: +- `Confirm` → return default value (no stdin read) +- `Select` → return error: "interactive selection unavailable; use --provider flag" +- `Input` → return default or error if no default +- `SecretInput` → return error: "use --api-key flag" + +**`cmd/obol/openclaw.go`** (line 36) — `openclaw onboard` is hardwired `Interactive: true`. Add a non-interactive path when all required flags are provided (`--id`, plus any other required inputs). Only fall through to interactive mode when flags are missing AND stdin is a TTY. + +**`cmd/obol/model.go`** (lines 62-84) — `model setup` enters interactive selection when `--provider` is omitted. In non-TTY/JSON mode, error with required flags instead. + +**`cmd/obol/model.go`** (lines 387-419) — `model pull` uses `bufio.NewReader(os.Stdin)` for interactive model selection. Same treatment. + +**`cmd/obol/sell.go`** (line 576-588) — `sell delete` confirmation uses raw `fmt.Scanln`. Migrate to `u.Confirm()` so the headless path is automatic. + +### Testing +- `internal/validate/validate_test.go`: Table-driven tests for path traversal variants, control char injection, valid inputs +- Test that `--output json` + missing required flags → clear error (not a hung prompt) +- Test that `openclaw onboard --id test -o json` works without interactive mode + +--- + +## Phase 3: Schema Introspection (`obol describe`) + +Let agents discover what the CLI accepts at runtime without parsing `--help` text. + +### 3A. Add `obol describe` command + +**`cmd/obol/describe.go`** (new) + +``` +obol describe # list all commands + flags as JSON +obol describe sell http # flags + ServiceOffer schema for that command +obol describe --schemas # dump resource schemas only +``` + +Walk urfave/cli's `*cli.Command` tree. For each command, emit: name, usage, flags (name, type, required, default, env vars, aliases), ArgsUsage. Output always JSON. + +### 3B. Schema registry + +**`internal/schemas/registry.go`** (new) — Map of schema names to JSON Schema generated from Go struct tags via `reflect`. Schemas: `ServiceOfferSpec`, `PaymentTerms`, `PriceTable`, `RegistrationSpec`. + +### 3C. Command metadata annotations + +Add `Metadata: map[string]any{"schema": "ServiceOfferSpec", "mutating": true}` to commands that create resources (sell http, sell inference, sell pricing). `obol describe` reads this and includes the schema in output. + +### Testing +- `cmd/obol/describe_test.go`: Valid JSON output, every command appears, schemas resolve, flag metadata matches actual flags + +--- + +## Phase 4: `--fields` Support + +Let agents limit response size to protect their context window. + +### 4A. Field mask implementation + +**`internal/ui/fields.go`** (new) — `FilterFields(data any, fields []string) any` using reflect on JSON tags. + +### 4B. Global `--fields` flag + +**`cmd/obol/main.go`** — Global `--fields` flag (comma-separated, requires `--output json`). Applied in `u.JSON()` before encoding. + +### Testing +- `--fields name,status` on `sell list -o json` returns only those fields +- `--fields` without `--output json` returns error + +--- + +## Phase 5: `--dry-run` for Mutating Commands + +Let agents validate before mutating. Safety rail. + +### 5A. Global `--dry-run` flag + +**`cmd/obol/main.go`** — Add `--dry-run` bool flag. + +### 5B. Priority commands + +| Command | Implementation | +|---------|---------------| +| `sell http` | Already builds manifest before `kubectlApply()` — return manifest instead of applying | +| `sell pricing` | Validate wallet/chain, show what would be written to ConfigMap | +| `network add` | Validate chain, show which RPCs would be added to eRPC config | +| `sell delete` | Validate name exists, show what would be deleted | + +Pattern: after validation, before execution, check `cmd.Root().Bool("dry-run")` and return a `DryRunResult{Command, Valid, WouldCreate, Manifest}` as JSON. + +### Testing +- `cmd/obol/dryrun_test.go`: `--dry-run sell http` returns manifest without kubectl apply, validation still runs in dry-run + +--- + +## Phase 6: Agent Context & Skills + +The 23 SKILL.md files are a strength, but there's no top-level `CONTEXT.md` encoding invariants agents can't intuit from `--help`. + +### 6A. Ship `CONTEXT.md` + +**`CONTEXT.md`** (repo root, also embedded in binary) — Agent-facing context file encoding: +- Always use `--output json` when parsing output programmatically +- Always use `--force` for non-interactive destructive operations +- Always use `--fields` on list commands to limit context window usage +- Always use `--dry-run` before mutating operations +- Use `obol describe ` to introspect flags and schemas +- Cluster commands require `OBOL_CONFIG_DIR` or a running stack (`obol stack up`) +- Payment wallet addresses must be 0x-prefixed, 42 chars +- Chain names: `base`, `base-sepolia` (not CAIP-2 format) + +### 6B. Update existing skills + +Review and update the 23 SKILL.md files to reference the new agent-friendly flags where relevant (e.g., the `sell` skill should mention `--from-json` and `--dry-run`). + +--- + +## Phase 7: MCP Surface (`obol mcp`) + +Expose the CLI as typed JSON-RPC tools over stdio. Depends on all previous phases. + +### 7A. New package `internal/mcp/` + +- `server.go` — MCP server over stdio using `github.com/mark3labs/mcp-go` +- `tools.go` — Tool definitions from the typed result functions built in Phase 1C (not by shelling out with `--output json`) +- `handlers.go` — Tool handlers that call the refactored return-typed-data functions directly + +### 7B. `obol mcp` command + +**`cmd/obol/mcp.go`** (new) — Starts MCP server. Exposes high-value tools only: +- sell: `sell_http`, `sell_list`, `sell_status`, `sell_pricing`, `sell_delete` +- network: `network_list`, `network_add`, `network_remove`, `network_status` +- model: `model_status`, `model_list`, `model_setup` +- openclaw: `openclaw_list`, `openclaw_onboard` +- utility: `version`, `update`, `tunnel_status` + +Excludes: kubectl/helm/k9s passthroughs, interactive-only commands, dangerous ops (stack purge/down). + +### Testing +- `internal/mcp/mcp_test.go`: Tool registration produces valid MCP definitions, stdin/stdout JSON-RPC round-trip + +--- + +## Key Files Summary + +| File | Changes | +|------|---------| +| `internal/ui/ui.go` | Add OutputMode, IsJSON(), NewWithAllOptions() | +| `internal/ui/output.go` | Add JSON() method, stderr redirect in JSON mode | +| `internal/ui/prompt.go` | Non-interactive behavior when JSON/non-TTY | +| `internal/ui/fields.go` | New — field mask filtering | +| `cmd/obol/main.go` | `--output`, `--dry-run`, `--fields` global flags + Before hook | +| `cmd/obol/sell.go` | JSON output, typed results, input validation, dry-run, --from-json, migrate Scanln to u.Confirm | +| `cmd/obol/network.go` | JSON output, typed results, input validation | +| `cmd/obol/model.go` | JSON output, typed results, input validation, headless paths | +| `cmd/obol/openclaw.go` | JSON output, typed results, input validation, headless onboard path | +| `cmd/obol/update.go` | Wire to global --output flag, deprecate local --json | +| `cmd/obol/describe.go` | New — schema introspection command | +| `cmd/obol/mcp.go` | New — `obol mcp` command | +| `internal/validate/validate.go` | New — input validation functions | +| `internal/schemas/registry.go` | New — JSON Schema generation from Go types | +| `internal/mcp/` | New package — MCP server, tools, handlers | +| `CONTEXT.md` | New — agent-facing context file | + +## Verification + +```bash +# Phase 1: JSON output + JSON input +obol sell list -o json | jq . +obol sell status -o json | jq . +obol version -o json | jq . +obol network list -o json | jq . +echo '{"upstream":{"service":"ollama","namespace":"llm","port":11434},...}' | obol sell http test --from-json - + +# Phase 2: Input validation + headless +obol sell http '../etc/passwd' --wallet 0x... --chain base-sepolia # should error +obol sell http 'valid-name' --wallet 'not-a-wallet' --chain base-sepolia # should error +echo '' | obol model setup -o json # should error with "use --provider flag", not hang + +# Phase 3: Schema introspection +obol describe | jq '.commands | length' +obol describe sell http | jq '.schema' + +# Phase 4: Fields +obol sell list -o json --fields name,namespace,status | jq . + +# Phase 5: Dry-run +obol sell http test-svc --wallet 0x... --chain base-sepolia --dry-run -o json | jq . + +# Phase 7: MCP +echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | obol mcp + +# Unit tests +go test ./internal/ui/ ./internal/validate/ ./internal/schemas/ ./internal/mcp/ ./cmd/obol/ +``` diff --git a/docs/plans/multi-network-sell.md b/docs/plans/multi-network-sell.md new file mode 100644 index 00000000..77b6bf01 --- /dev/null +++ b/docs/plans/multi-network-sell.md @@ -0,0 +1,387 @@ +# Multi-Network Sell Command + UX Improvements + +## Context + +The `obol sell` command currently only supports ERC-8004 registration on Base Sepolia, requires manual private key management via `--private-key-file`, and forces users to specify all flags explicitly. We want to: + +1. Support 3 registration networks: **base-sepolia**, **base**, **ethereum mainnet** +2. Support **multi-chain** registration: `--chain mainnet,base` registers on both, best-effort +3. Use the **remote-signer** for all signing (not private key extraction) — EIP-712 typed data + transaction signing via its REST API +4. Use **sponsored registration** (zero gas) on ethereum mainnet via howto8004.com +5. Use the **local eRPC** (`localhost/rpc`) for chain access instead of public RPCs +6. Add **interactive prompts** using `charmbracelet/huh` with good defaults +7. **Auto-discover** the remote-signer wallet address +8. Add **ethereum mainnet** as a valid x402 payment chain + +Frontend deferred to follow-up PR. EIP-7702 handled server-side by sponsor — no CLI implementation needed. + +### Network Matrix + +| Network | x402 Payment | x402 Facilitator | ERC-8004 Registration | Sponsored Reg | +|---------|-------------|-------------------|----------------------|---------------| +| base-sepolia | Yes | `facilitator.x402.rs` | Yes (direct tx via remote-signer) | No | +| base | Yes | `x402.gcp.obol.tech` | Yes (direct tx via remote-signer) | No | +| ethereum | Yes (no facilitator yet) | TBD | Yes | Yes (`sponsored.howto8004.com/api/register`) | + +--- + +## Phase 1: Multi-Network ERC-8004 Registry Config + +### `internal/erc8004/networks.go` (new) + +```go +type NetworkConfig struct { + Name string // "base-sepolia", "base", "ethereum" + ChainID int64 + RegistryAddress string // per-chain registry address + SponsorURL string // empty if no sponsor + DelegateAddress string // EIP-7702 delegate (for sponsored flow) + ERPCNetwork string // eRPC path segment: "base-sepolia", "base", "mainnet" +} + +func ResolveNetwork(name string) (NetworkConfig, error) +func ResolveNetworks(csv string) ([]NetworkConfig, error) // "mainnet,base" → []NetworkConfig +func SupportedNetworks() []NetworkConfig +``` + +Three entries: +- `base-sepolia`: chainID 84532, registry `0x8004A818BFB912233c491871b3d84c89A494BD9e`, eRPC `base-sepolia` +- `base`: chainID 8453, registry TBD (confirm CREATE2 address), eRPC `base` +- `ethereum` / `mainnet`: chainID 1, registry `0x8004A169FB4a3325136EB29fA0ceB6D2e539a432`, sponsor `https://sponsored.howto8004.com/api/register`, delegate `0x77fb3D2ff6dB9dcbF1b7E0693b3c746B30499eE8`, eRPC `mainnet` + +RPC URL is **not** in NetworkConfig — always use local eRPC at `http://localhost/rpc/{ERPCNetwork}` (from host via k3d port mapping). + +### `internal/erc8004/client.go` + +- Add `NewClientForNetwork(ctx, rpcBaseURL string, net NetworkConfig) (*Client, error)` — constructs RPC URL as `rpcBaseURL + "/" + net.ERPCNetwork`, uses `net.RegistryAddress` +- Keep `NewClient(ctx, rpcURL)` as backward-compat wrapper + +### Files +- `internal/erc8004/networks.go` (new) +- `internal/erc8004/networks_test.go` (new) +- `internal/erc8004/client.go` (add `NewClientForNetwork`) + +--- + +## Phase 2: Remote-Signer Integration for Registration + +### Architecture + +The remote-signer REST API at port 9000 already supports: +- `POST /api/v1/sign/{address}/transaction` — sign raw transactions +- `POST /api/v1/sign/{address}/typed-data` — sign EIP-712 typed data +- `GET /api/v1/keys` — list loaded wallet addresses + +From the host CLI, access via **temporary port-forward** to `remote-signer:9000` (same pattern as `openclaw cli`). + +### `internal/erc8004/signer.go` (new) + +```go +// RemoteSigner wraps the remote-signer REST API for ERC-8004 operations. +type RemoteSigner struct { + baseURL string // e.g. "http://localhost:19000" (port-forwarded) +} + +func NewRemoteSigner(baseURL string) *RemoteSigner + +// GetAddress returns the first loaded signing address. +func (s *RemoteSigner) GetAddress(ctx context.Context) (common.Address, error) + +// SignTransaction signs an EIP-1559 transaction for direct on-chain registration. +func (s *RemoteSigner) SignTransaction(ctx context.Context, addr common.Address, tx SignTxRequest) ([]byte, error) + +// SignTypedData signs EIP-712 typed data (for sponsored registration). +func (s *RemoteSigner) SignTypedData(ctx context.Context, addr common.Address, data EIP712TypedData) ([]byte, error) +``` + +### `internal/erc8004/register.go` (new) + +Two registration paths: + +**Direct on-chain** (base-sepolia, base): +1. Port-forward to remote-signer +2. `signer.GetAddress()` → wallet address +3. Build `register(agentURI)` calldata +4. Get nonce + gas estimates from eRPC +5. `signer.SignTransaction()` → signed tx +6. `eth_sendRawTransaction` via eRPC +7. Wait for receipt, parse `Registered` event + +**Sponsored** (ethereum mainnet): +1. Port-forward to remote-signer +2. `signer.GetAddress()` → wallet address +3. `signer.SignTypedData()` → EIP-712 authorization + registration intent signatures +4. POST to `net.SponsorURL` with signatures +5. Parse response `{success, agentId, txHash}` + +### Port-Forward Helper + +Reuse or adapt the pattern from `openclaw cli` (`cmd/obol/openclaw.go`). New helper: + +```go +// portForwardRemoteSigner starts a port-forward to the remote-signer in the +// given namespace and returns the local URL + cleanup function. +func portForwardRemoteSigner(cfg *config.Config, namespace string) (baseURL string, cleanup func(), err error) +``` + +### Files +- `internal/erc8004/signer.go` (new — remote-signer REST client) +- `internal/erc8004/signer_test.go` (new — HTTP mock tests) +- `internal/erc8004/register.go` (new — direct + sponsored registration flows) +- `internal/erc8004/sponsor.go` (new — sponsored API client, EIP-712 types) +- `internal/erc8004/sponsor_test.go` (new) + +--- + +## Phase 3: Wallet Auto-Discovery + +### `internal/openclaw/wallet_resolve.go` (new) + +```go +// ResolveWalletAddress returns the wallet address from the single OpenClaw instance. +// 0 instances → error, 1 → auto-select, 2+ → error suggesting --wallet. +func ResolveWalletAddress(cfg *config.Config) (string, error) + +// ResolveInstanceNamespace returns the namespace of the single OpenClaw instance +// (needed for port-forwarding to the remote-signer in that namespace). +func ResolveInstanceNamespace(cfg *config.Config) (string, error) +``` + +Flow: +1. `ListInstanceIDs(cfg)` → instance IDs +2. 0 → error, 1 → read wallet.json, 2+ → error with list of addresses +3. `ReadWalletMetadata(DeploymentPath(cfg, id))` → `WalletInfo.Address` + +**No private key extraction.** The address is all we need for auto-discovery. Signing goes through the remote-signer API. + +### Files +- `internal/openclaw/wallet_resolve.go` (new) +- `internal/openclaw/wallet_resolve_test.go` (new) + +--- + +## Phase 4: Rewrite `sell register` + +### `cmd/obol/sell.go` — `sellRegisterCommand` + +**New flags:** + +| Flag | Type | Default | Notes | +|------|------|---------|-------| +| `--chain` | string | `base-sepolia` | Comma-separated: `base-sepolia,base,mainnet`. Register on each, best-effort | +| `--sponsored` | bool | auto | `true` when network has sponsor URL | +| `--endpoint` | string | auto | Auto-detected from tunnel | +| `--name` | string | `Obol Agent` | Agent name for registration | +| `--description` | string | smart default | Auto-generated from stack info | +| `--image` | string | smart default | Default Obol logo URL | +| `--private-key-file` | string | | Fallback — used only if no remote-signer detected | + +**Removed:** `--private-key` (deprecated), `--rpc-url` (use local eRPC) + +**Action logic:** +1. Parse `--chain` → `erc8004.ResolveNetworks(chainCSV)` → `[]NetworkConfig` +2. Resolve wallet: try `openclaw.ResolveWalletAddress(cfg)`. If found, use remote-signer path. If not, require `--private-key-file`. +3. Resolve endpoint: `--endpoint` if set, else tunnel auto-detect +4. For each network (best-effort): + a. If sponsored + network has sponsor → sponsored path (sign EIP-712 via remote-signer, POST to sponsor) + b. Else → direct path (sign tx via remote-signer, broadcast via eRPC) + c. On success: print CAIP-10 registry line + d. On failure: print warning, continue to next chain +5. Update `agent-registration.json` with all successful registrations in the `registrations[]` array + +### Files +- `cmd/obol/sell.go` (rewrite `sellRegisterCommand`) +- `cmd/obol/sell_test.go` (update `TestSellRegister_Flags`) + +--- + +## Phase 5: Interactive Prompts with `charmbracelet/huh` + +### New dependency + +`go get github.com/charmbracelet/huh` + +### Signature change + +`sellCommand(cfg *config.Config)` → `sellCommand(cfg *config.Config, u *ui.UI)` (match `openclawCommand` pattern). Wire from `main.go`. + +### TTY guard + +```go +import "golang.org/x/term" +isInteractive := term.IsTerminal(int(os.Stdin.Fd())) +``` + +### `sell inference` interactive flow: + +| Field | Default | Prompt type | When prompted | +|-------|---------|-------------|---------------| +| Name | (required) | Text input | No positional arg | +| Model | (required) | Select from Ollama models | `--model` not set | +| Wallet | auto-discovered | Text (pre-filled) | Auto-discover fails | +| Chain | `base-sepolia` | Select | Using default | +| Price | `0.001` | Text (pre-filled) | Confirm or override | + +### `sell http` interactive flow: + +| Field | Default | Prompt type | When prompted | +|-------|---------|-------------|---------------| +| Name | (required) | Text input | No positional arg | +| Upstream | (required) | Text input | `--upstream` not set | +| Port | `8080` | Text (pre-filled) | Confirm | +| Wallet | auto-discovered | Text (pre-filled) | Auto-discover fails | +| Chain | `base-sepolia` | Select | `--chain` not set (remove `Required: true`) | +| Price model | `perRequest` | Select | No price flag set | +| Price value | `0.001` | Text | After model selected | +| Register? | `false` | Confirm | Not explicitly set | + +### `sell register` interactive flow: + +| Field | Default | Prompt type | When prompted | +|-------|---------|-------------|---------------| +| Chain(s) | `base-sepolia` | Multi-select | Using default | +| Name | `Obol Agent` | Text (pre-filled) | Confirm or override | +| Description | auto-generated | Text (pre-filled) | Confirm or override | +| Image | default logo URL | Text (pre-filled) | Confirm or override | +| Sponsored? | yes (when available) | Confirm | Network supports it | +| Endpoint | auto-detected | Text (pre-filled) | Tunnel fails | + +### Non-interactive path + +All prompts gated on `isInteractive`. When not TTY: flag validation applies, defaults used, no prompts. + +### Files +- `go.mod` / `go.sum` (add `charmbracelet/huh`) +- `cmd/obol/sell.go` (add prompts to inference, http, register) +- `cmd/obol/main.go` (wire `*ui.UI` to `sellCommand`) + +--- + +## Phase 6: x402 Payment Chain Updates + +### `cmd/obol/sell.go` — `resolveX402Chain` + +Add: +```go +case "ethereum", "ethereum-mainnet", "mainnet": + return x402.EthereumMainnet, nil +``` + +If `x402.EthereumMainnet` doesn't exist in the upstream `mark3labs/x402-go` library, define a local constant. + +### `cmd/obol/sell.go` — `sellPricingCommand` + +- Auto-discover wallet via `openclaw.ResolveWalletAddress(cfg)` when `--wallet` not set +- Remove `Required: true` from `--wallet` +- Update chain usage help: `"Payment chain (base-sepolia, base, ethereum)"` + +### Files +- `cmd/obol/sell.go` (`resolveX402Chain`, `sellPricingCommand`) +- `internal/x402/config.go` (`ResolveChain` — add ethereum) +- `internal/x402/config_test.go` (add ethereum test cases) +- `cmd/obol/sell_test.go` (update `TestResolveX402Chain`) + +--- + +## Phase 7: Tests & Docs + +### Tests +- `internal/erc8004/networks_test.go`: `ResolveNetwork` all chains, `ResolveNetworks` CSV parsing +- `internal/erc8004/signer_test.go`: HTTP mock for remote-signer API +- `internal/erc8004/sponsor_test.go`: EIP-712 construction, HTTP mock +- `internal/openclaw/wallet_resolve_test.go`: 0/1/multi instance +- `cmd/obol/sell_test.go`: Updated register flags, multi-chain parsing, new x402 chains + +### Docs +- `CLAUDE.md`: Update CLI command table, add `--chain` multi-value, remove `--rpc-url` +- `internal/embed/skills/sell/SKILL.md`: New registration flow, multi-network, remote-signer +- `internal/embed/skills/discovery/SKILL.md`: Multi-network registry info +- `cmd/obol/main.go`: Update root help text for sell register + +--- + +## Dependency Graph + +``` +Phase 1 (multi-network config) + ├──→ Phase 2 (remote-signer integration + registration flows) + └──→ Phase 3 (wallet auto-discovery) + │ + v + Phase 4 (rewrite sell register) ← depends on 1+2+3 + │ + v + Phase 5 (interactive prompts) ← depends on 3 (wallet discovery) + │ + v + Phase 6 (x402 payment chains + sell pricing) + │ + v + Phase 7 (tests & docs — throughout) +``` + +--- + +## Key Design Decisions + +1. **Remote-signer for all signing** — Never extract private keys. Use `POST /api/v1/sign/{address}/transaction` for direct registration, `POST /api/v1/sign/{address}/typed-data` for sponsored EIP-712. Access via temporary port-forward. + +2. **Local eRPC for all chain access** — `http://localhost/rpc/{network}` via k3d port mapping. No public RPCs. eRPC already has upstreams for mainnet, base, base-sepolia. + +3. **Multi-chain `--chain mainnet,base`** — Same agentURI and wallet registered on each chain. Best-effort: if one fails, continue to next. Update `registrations[]` array in `agent-registration.json` with all successes. + +4. **Prefer remote-signer, fallback to `--private-key-file`** — Auto-discover wallet → use remote-signer. If no instance found, accept `--private-key-file` for standalone usage. + +5. **Good defaults for registration metadata** — Pre-fill name (`Obol Agent`), description, image URL. Interactive mode lets users confirm or override each. + +6. **`charmbracelet/huh` for prompts** — Modern TUI with select, input, confirm. TTY-gated. + +--- + +## Key Files Summary + +| File | Change | +|------|--------| +| `internal/erc8004/networks.go` | New — multi-network config registry | +| `internal/erc8004/signer.go` | New — remote-signer REST API client | +| `internal/erc8004/register.go` | New — direct + sponsored registration flows | +| `internal/erc8004/sponsor.go` | New — sponsored API client | +| `internal/erc8004/client.go` | Add `NewClientForNetwork` | +| `internal/openclaw/wallet_resolve.go` | New — wallet address + namespace discovery | +| `cmd/obol/sell.go` | Rewrite register, add prompts to inference/http/register/pricing | +| `cmd/obol/main.go` | Wire `*ui.UI`, update help text | +| `cmd/obol/sell_test.go` | Update all affected tests | +| `internal/x402/config.go` | Add ethereum mainnet chain | + +--- + +## Verification + +```bash +# Phase 1 +go test ./internal/erc8004/ -run TestResolveNetwork + +# Phase 2 (unit — mock remote-signer) +go test ./internal/erc8004/ -run TestRemoteSigner +go test ./internal/erc8004/ -run TestSponsored + +# Phase 3 +go test ./internal/openclaw/ -run TestResolveWallet + +# Phase 4+5 (manual — needs running cluster + tunnel) +obol sell register --chain base-sepolia # direct tx via remote-signer +obol sell register --chain mainnet --sponsored # zero-gas via howto8004 +obol sell register --chain mainnet,base # multi-chain best-effort +obol sell inference # interactive prompts +obol sell http # interactive prompts +obol sell register # interactive with defaults to confirm + +# Phase 6 +obol sell pricing --chain base # auto-discovers wallet + +# All unit tests +go test ./cmd/obol/ -run TestSell +go test ./internal/erc8004/ +go test ./internal/openclaw/ -run TestResolve +go test ./internal/x402/ -run TestResolveChain +``` diff --git a/go.mod b/go.mod index 12607c6b..ec26bec8 100644 --- a/go.mod +++ b/go.mod @@ -29,15 +29,21 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/StackExchange/wmi v1.2.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.2 // indirect github.com/blendle/zapdriver v1.3.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/huh v1.0.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/consensys/gnark-crypto v0.19.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect @@ -47,6 +53,8 @@ require ( github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect github.com/ethereum/go-verkle v0.2.2 // indirect github.com/fatih/color v1.18.0 // indirect @@ -70,13 +78,17 @@ require ( github.com/logrusorgru/aurora v2.0.3+incompatible // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect github.com/mr-tron/base58 v1.2.0 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/common v0.42.0 // indirect @@ -97,7 +109,8 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/ratelimit v0.3.1 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/sync v0.17.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 55e9ec45..d2b5163c 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDO github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -21,18 +23,32 @@ github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= @@ -76,10 +92,14 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvw github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiDR1gg0= github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk= github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s= github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= @@ -208,6 +228,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= @@ -216,6 +238,8 @@ github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= @@ -229,6 +253,10 @@ github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= @@ -362,6 +390,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -372,6 +402,7 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/erc8004/client.go b/internal/erc8004/client.go index 919d26d8..ff063a44 100644 --- a/internal/erc8004/client.go +++ b/internal/erc8004/client.go @@ -10,10 +10,11 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" ) -// Client interacts with the ERC-8004 Identity Registry on Base Sepolia. +// Client interacts with the ERC-8004 Identity Registry. type Client struct { eth *ethclient.Client contract *bind.BoundContract @@ -22,8 +23,21 @@ type Client struct { chainID *big.Int } -// NewClient connects to rpcURL and binds to the Identity Registry contract. +// NewClient connects to rpcURL and binds to the Identity Registry on Base Sepolia. +// For multi-network support, use NewClientForNetwork instead. func NewClient(ctx context.Context, rpcURL string) (*Client, error) { + return newClient(ctx, rpcURL, IdentityRegistryBaseSepolia) +} + +// NewClientForNetwork connects to the eRPC base URL and binds to the Identity +// Registry on the given network. The RPC URL is constructed as +// rpcBaseURL + "/" + net.ERPCNetwork. +func NewClientForNetwork(ctx context.Context, rpcBaseURL string, net NetworkConfig) (*Client, error) { + rpcURL := strings.TrimRight(rpcBaseURL, "/") + "/" + net.ERPCNetwork + return newClient(ctx, rpcURL, net.RegistryAddress) +} + +func newClient(ctx context.Context, rpcURL, registryAddr string) (*Client, error) { eth, err := ethclient.DialContext(ctx, rpcURL) if err != nil { return nil, fmt.Errorf("erc8004: dial %s: %w", rpcURL, err) @@ -41,7 +55,7 @@ func NewClient(ctx context.Context, rpcURL string) (*Client, error) { return nil, fmt.Errorf("erc8004: parse abi: %w", err) } - addr := common.HexToAddress(IdentityRegistryBaseSepolia) + addr := common.HexToAddress(registryAddr) contract := bind.NewBoundContract(addr, parsed, eth, eth, eth) return &Client{ @@ -58,7 +72,17 @@ func (c *Client) Close() { c.eth.Close() } -// Register mints a new agent NFT with the given agentURI. +// ChainID returns the chain ID for this client's connection. +func (c *Client) ChainID() *big.Int { + return new(big.Int).Set(c.chainID) +} + +// ETH returns the underlying ethclient for direct RPC calls. +func (c *Client) ETH() *ethclient.Client { + return c.eth +} + +// Register mints a new agent NFT with the given agentURI using a raw private key. // Returns the minted agentId (token ID). func (c *Client) Register(ctx context.Context, key *ecdsa.PrivateKey, agentURI string) (*big.Int, error) { opts, err := bind.NewKeyedTransactorWithChainID(key, c.chainID) @@ -66,7 +90,12 @@ func (c *Client) Register(ctx context.Context, key *ecdsa.PrivateKey, agentURI s return nil, fmt.Errorf("erc8004: transactor: %w", err) } opts.Context = ctx + return c.RegisterWithOpts(ctx, opts, agentURI) +} +// RegisterWithOpts mints a new agent NFT using the provided TransactOpts. +// This allows callers to supply a custom Signer (e.g. remote-signer). +func (c *Client) RegisterWithOpts(ctx context.Context, opts *bind.TransactOpts, agentURI string) (*big.Int, error) { tx, err := c.contract.Transact(opts, "register", agentURI) if err != nil { return nil, fmt.Errorf("erc8004: register tx: %w", err) @@ -77,7 +106,11 @@ func (c *Client) Register(ctx context.Context, key *ecdsa.PrivateKey, agentURI s return nil, fmt.Errorf("erc8004: wait mined: %w", err) } - // Parse the Registered event to extract agentId. + return c.parseRegisteredEvent(receipt, tx.Hash()) +} + +// parseRegisteredEvent extracts the agentId from the Registered event in a receipt. +func (c *Client) parseRegisteredEvent(receipt *types.Receipt, txHash common.Hash) (*big.Int, error) { registeredEvent := c.parsedABI.Events["Registered"] for _, vLog := range receipt.Logs { if vLog.Topics[0] != registeredEvent.ID { @@ -88,7 +121,20 @@ func (c *Client) Register(ctx context.Context, key *ecdsa.PrivateKey, agentURI s return agentID, nil } - return nil, fmt.Errorf("erc8004: Registered event not found in receipt (tx: %s)", tx.Hash().Hex()) + return nil, fmt.Errorf("erc8004: Registered event not found in receipt (tx: %s)", txHash.Hex()) +} + +// SetMetadataWithOpts stores key-value metadata using the provided TransactOpts. +func (c *Client) SetMetadataWithOpts(ctx context.Context, opts *bind.TransactOpts, agentID *big.Int, k string, v []byte) error { + tx, err := c.contract.Transact(opts, "setMetadata", agentID, k, v) + if err != nil { + return fmt.Errorf("erc8004: setMetadata tx: %w", err) + } + + if _, err := bind.WaitMined(ctx, c.eth, tx); err != nil { + return fmt.Errorf("erc8004: wait mined: %w", err) + } + return nil } // SetAgentURI updates the agentURI for an existing agent NFT. diff --git a/internal/erc8004/networks.go b/internal/erc8004/networks.go new file mode 100644 index 00000000..80208c67 --- /dev/null +++ b/internal/erc8004/networks.go @@ -0,0 +1,116 @@ +package erc8004 + +import ( + "fmt" + "strings" +) + +// NetworkConfig describes an ERC-8004 registration network. +type NetworkConfig struct { + // Name is the canonical network name (e.g. "base-sepolia", "base", "ethereum"). + Name string + + // ChainID is the EIP-155 chain identifier. + ChainID int64 + + // RegistryAddress is the ERC-8004 Identity Registry contract address on this chain. + RegistryAddress string + + // SponsorURL is the sponsored registration API endpoint (empty if not available). + SponsorURL string + + // DelegateAddress is the EIP-7702 delegation contract used by the sponsor. + DelegateAddress string + + // ERPCNetwork is the path segment used by eRPC to route to this chain + // (e.g. "base-sepolia", "base", "mainnet"). + ERPCNetwork string +} + +// HasSponsor returns true if this network supports sponsored (zero-gas) registration. +func (n NetworkConfig) HasSponsor() bool { + return n.SponsorURL != "" +} + +// CAIP10Registry returns the CAIP-10 formatted registry identifier. +func (n NetworkConfig) CAIP10Registry() string { + return fmt.Sprintf("eip155:%d:%s", n.ChainID, n.RegistryAddress) +} + +// Predefined network configurations. +var ( + BaseSepolia = NetworkConfig{ + Name: "base-sepolia", + ChainID: 84532, + RegistryAddress: IdentityRegistryBaseSepolia, + ERPCNetwork: "base-sepolia", + } + + Base = NetworkConfig{ + Name: "base", + ChainID: 8453, + RegistryAddress: IdentityRegistryBaseSepolia, // CREATE2 — same address across chains + ERPCNetwork: "base", + } + + Ethereum = NetworkConfig{ + Name: "ethereum", + ChainID: 1, + RegistryAddress: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + SponsorURL: "https://sponsored.howto8004.com/api/register", + DelegateAddress: "0x77fb3D2ff6dB9dcbF1b7E0693b3c746B30499eE8", + ERPCNetwork: "mainnet", + } +) + +// allNetworks is the set of supported registration networks. +var allNetworks = []NetworkConfig{BaseSepolia, Base, Ethereum} + +// ResolveNetwork maps a network name to its configuration. +func ResolveNetwork(name string) (NetworkConfig, error) { + switch strings.ToLower(strings.TrimSpace(name)) { + case "base-sepolia": + return BaseSepolia, nil + case "base", "base-mainnet": + return Base, nil + case "ethereum", "ethereum-mainnet", "mainnet": + return Ethereum, nil + default: + return NetworkConfig{}, fmt.Errorf("unsupported registration network: %q (supported: base-sepolia, base, ethereum)", name) + } +} + +// ResolveNetworks parses a comma-separated list of network names. +func ResolveNetworks(csv string) ([]NetworkConfig, error) { + parts := strings.Split(csv, ",") + seen := map[string]bool{} + var result []NetworkConfig + + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + net, err := ResolveNetwork(name) + if err != nil { + return nil, err + } + if seen[net.Name] { + continue // deduplicate + } + seen[net.Name] = true + result = append(result, net) + } + + if len(result) == 0 { + return nil, fmt.Errorf("no valid networks specified") + } + return result, nil +} + +// SupportedNetworks returns all supported registration networks. +func SupportedNetworks() []NetworkConfig { + out := make([]NetworkConfig, len(allNetworks)) + copy(out, allNetworks) + return out +} diff --git a/internal/erc8004/networks_test.go b/internal/erc8004/networks_test.go new file mode 100644 index 00000000..ccdf7c7c --- /dev/null +++ b/internal/erc8004/networks_test.go @@ -0,0 +1,89 @@ +package erc8004 + +import ( + "testing" +) + +func TestResolveNetwork(t *testing.T) { + tests := []struct { + name string + want string + wantErr bool + }{ + {"base-sepolia", "base-sepolia", false}, + {"base", "base", false}, + {"base-mainnet", "base", false}, + {"ethereum", "ethereum", false}, + {"mainnet", "ethereum", false}, + {"ethereum-mainnet", "ethereum", false}, + {"unknown", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveNetwork(tt.name) + if (err != nil) != tt.wantErr { + t.Fatalf("ResolveNetwork(%q) error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + if !tt.wantErr && got.Name != tt.want { + t.Errorf("ResolveNetwork(%q).Name = %q, want %q", tt.name, got.Name, tt.want) + } + }) + } +} + +func TestResolveNetworks(t *testing.T) { + tests := []struct { + csv string + want int + wantErr bool + }{ + {"base-sepolia", 1, false}, + {"mainnet,base", 2, false}, + {"base-sepolia,base,ethereum", 3, false}, + {"base,base", 1, false}, // deduplicate + {"mainnet,ethereum", 1, false}, // same network, different aliases + {"", 0, true}, + {"unknown", 0, true}, + {"base,unknown", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.csv, func(t *testing.T) { + got, err := ResolveNetworks(tt.csv) + if (err != nil) != tt.wantErr { + t.Fatalf("ResolveNetworks(%q) error = %v, wantErr %v", tt.csv, err, tt.wantErr) + } + if !tt.wantErr && len(got) != tt.want { + t.Errorf("ResolveNetworks(%q) returned %d networks, want %d", tt.csv, len(got), tt.want) + } + }) + } +} + +func TestNetworkConfig_HasSponsor(t *testing.T) { + if BaseSepolia.HasSponsor() { + t.Error("BaseSepolia should not have a sponsor") + } + if Base.HasSponsor() { + t.Error("Base should not have a sponsor") + } + if !Ethereum.HasSponsor() { + t.Error("Ethereum should have a sponsor") + } +} + +func TestNetworkConfig_CAIP10Registry(t *testing.T) { + got := BaseSepolia.CAIP10Registry() + want := "eip155:84532:" + IdentityRegistryBaseSepolia + if got != want { + t.Errorf("BaseSepolia.CAIP10Registry() = %q, want %q", got, want) + } +} + +func TestSupportedNetworks(t *testing.T) { + nets := SupportedNetworks() + if len(nets) != 3 { + t.Fatalf("SupportedNetworks() returned %d, want 3", len(nets)) + } +} diff --git a/internal/erc8004/signer.go b/internal/erc8004/signer.go new file mode 100644 index 00000000..537ec3fd --- /dev/null +++ b/internal/erc8004/signer.go @@ -0,0 +1,238 @@ +package erc8004 + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rlp" +) + +// RemoteSigner wraps the remote-signer REST API for signing operations. +// It communicates with the signer over HTTP (typically via a port-forward). +type RemoteSigner struct { + baseURL string + client *http.Client +} + +// NewRemoteSigner creates a client for the remote-signer API at the given base URL. +func NewRemoteSigner(baseURL string) *RemoteSigner { + return &RemoteSigner{ + baseURL: baseURL, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// keysResponse is the response from GET /api/v1/keys. +type keysResponse struct { + Keys []string `json:"keys"` +} + +// GetAddress returns the first loaded signing address from the remote-signer. +func (s *RemoteSigner) GetAddress(ctx context.Context) (common.Address, error) { + url := s.baseURL + "/api/v1/keys" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return common.Address{}, fmt.Errorf("remote-signer: build request: %w", err) + } + + resp, err := s.client.Do(req) + if err != nil { + return common.Address{}, fmt.Errorf("remote-signer: get keys: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return common.Address{}, fmt.Errorf("remote-signer: get keys: HTTP %d: %s", resp.StatusCode, body) + } + + var kr keysResponse + if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil { + return common.Address{}, fmt.Errorf("remote-signer: decode keys: %w", err) + } + if len(kr.Keys) == 0 { + return common.Address{}, fmt.Errorf("remote-signer: no signing keys loaded") + } + + return common.HexToAddress(kr.Keys[0]), nil +} + +// SignTxRequest contains the fields for signing an EIP-1559 transaction. +type SignTxRequest struct { + ChainID string `json:"chain_id"` + To string `json:"to"` + Nonce string `json:"nonce"` + GasLimit string `json:"gas_limit"` + MaxFeePerGas string `json:"max_fee_per_gas"` + MaxPriorityFeePerGas string `json:"max_priority_fee_per_gas"` + Value string `json:"value"` + Data string `json:"data"` +} + +// signResponse is the response from signing endpoints. +type signResponse struct { + SignedTransaction string `json:"signed_transaction,omitempty"` + Signature string `json:"signature,omitempty"` + Error string `json:"error,omitempty"` +} + +// SignTransaction signs an EIP-1559 transaction via the remote-signer. +// Returns the RLP-encoded signed transaction bytes (hex-encoded with 0x prefix). +func (s *RemoteSigner) SignTransaction(ctx context.Context, addr common.Address, tx SignTxRequest) (string, error) { + url := fmt.Sprintf("%s/api/v1/sign/%s/transaction", s.baseURL, addr.Hex()) + + body, err := json.Marshal(tx) + if err != nil { + return "", fmt.Errorf("remote-signer: marshal tx: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("remote-signer: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return "", fmt.Errorf("remote-signer: sign transaction: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("remote-signer: sign transaction: HTTP %d: %s", resp.StatusCode, body) + } + + var sr signResponse + if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil { + return "", fmt.Errorf("remote-signer: decode response: %w", err) + } + if sr.Error != "" { + return "", fmt.Errorf("remote-signer: %s", sr.Error) + } + + return sr.SignedTransaction, nil +} + +// EIP712TypedData represents a full EIP-712 typed data structure for signing. +type EIP712TypedData struct { + Types map[string][]EIP712Field `json:"types"` + PrimaryType string `json:"primaryType"` + Domain map[string]interface{} `json:"domain"` + Message map[string]interface{} `json:"message"` +} + +// EIP712Field describes a single field in an EIP-712 type. +type EIP712Field struct { + Name string `json:"name"` + Type string `json:"type"` +} + +// SignTypedData signs EIP-712 typed data via the remote-signer. +// Returns the 65-byte signature as a hex string with 0x prefix. +func (s *RemoteSigner) SignTypedData(ctx context.Context, addr common.Address, data EIP712TypedData) (string, error) { + url := fmt.Sprintf("%s/api/v1/sign/%s/typed-data", s.baseURL, addr.Hex()) + + body, err := json.Marshal(data) + if err != nil { + return "", fmt.Errorf("remote-signer: marshal typed data: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("remote-signer: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return "", fmt.Errorf("remote-signer: sign typed data: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("remote-signer: sign typed data: HTTP %d: %s", resp.StatusCode, body) + } + + var sr signResponse + if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil { + return "", fmt.Errorf("remote-signer: decode response: %w", err) + } + if sr.Error != "" { + return "", fmt.Errorf("remote-signer: %s", sr.Error) + } + + return sr.Signature, nil +} + +// RemoteTransactOpts creates a bind.TransactOpts that delegates signing to the +// remote-signer. The returned opts can be used with Client.RegisterWithOpts and +// Client.SetMetadataWithOpts. +func (s *RemoteSigner) RemoteTransactOpts(ctx context.Context, addr common.Address, chainID *big.Int) *bind.TransactOpts { + return &bind.TransactOpts{ + From: addr, + Context: ctx, + Signer: func(fromAddr common.Address, tx *types.Transaction) (*types.Transaction, error) { + // Convert the unsigned transaction to a SignTxRequest. + var toAddr string + if tx.To() != nil { + toAddr = tx.To().Hex() + } + req := SignTxRequest{ + ChainID: chainID.String(), + To: toAddr, + Nonce: fmt.Sprintf("%d", tx.Nonce()), + GasLimit: fmt.Sprintf("%d", tx.Gas()), + Value: tx.Value().String(), + Data: "0x" + hex.EncodeToString(tx.Data()), + } + // Use EIP-1559 fields if available, otherwise legacy gas price. + if tx.GasFeeCap() != nil && tx.GasFeeCap().Sign() > 0 { + req.MaxFeePerGas = tx.GasFeeCap().String() + req.MaxPriorityFeePerGas = tx.GasTipCap().String() + } else if tx.GasPrice() != nil { + req.MaxFeePerGas = tx.GasPrice().String() + req.MaxPriorityFeePerGas = tx.GasPrice().String() + } + + signedHex, err := s.SignTransaction(ctx, fromAddr, req) + if err != nil { + return nil, fmt.Errorf("remote sign: %w", err) + } + + // Decode the signed transaction RLP returned by the signer. + rawBytes, err := hexToBytes(signedHex) + if err != nil { + return nil, fmt.Errorf("decode signed tx: %w", err) + } + + var signedTx types.Transaction + if err := rlp.DecodeBytes(rawBytes, &signedTx); err != nil { + // Try as typed tx envelope (EIP-2718). + if decErr := signedTx.UnmarshalBinary(rawBytes); decErr != nil { + return nil, fmt.Errorf("decode signed tx (rlp: %v, binary: %v)", err, decErr) + } + } + return &signedTx, nil + }, + } +} + +// hexToBytes decodes a hex string (with optional 0x prefix) to bytes. +func hexToBytes(s string) ([]byte, error) { + if len(s) >= 2 && s[:2] == "0x" { + s = s[2:] + } + return hex.DecodeString(s) +} diff --git a/internal/erc8004/signer_test.go b/internal/erc8004/signer_test.go new file mode 100644 index 00000000..87270809 --- /dev/null +++ b/internal/erc8004/signer_test.go @@ -0,0 +1,261 @@ +package erc8004 + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestRemoteSigner_GetAddress(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/keys" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(keysResponse{ + Keys: []string{"0x1234567890abcdef1234567890abcdef12345678"}, + }) + })) + defer srv.Close() + + signer := NewRemoteSigner(srv.URL) + addr, err := signer.GetAddress(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + if addr != want { + t.Errorf("got %s, want %s", addr.Hex(), want.Hex()) + } +} + +func TestRemoteSigner_GetAddress_NoKeys(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(keysResponse{Keys: []string{}}) + })) + defer srv.Close() + + signer := NewRemoteSigner(srv.URL) + _, err := signer.GetAddress(context.Background()) + if err == nil { + t.Fatal("expected error for no keys") + } +} + +func TestRemoteSigner_SignTransaction(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + + var tx SignTxRequest + if err := json.NewDecoder(r.Body).Decode(&tx); err != nil { + t.Fatalf("decode request: %v", err) + } + if tx.ChainID != "84532" { + t.Errorf("chain_id = %q, want 84532", tx.ChainID) + } + + json.NewEncoder(w).Encode(signResponse{ + SignedTransaction: "0x02f8deadbeef", + }) + })) + defer srv.Close() + + signer := NewRemoteSigner(srv.URL) + addr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + signed, err := signer.SignTransaction(context.Background(), addr, SignTxRequest{ + ChainID: "84532", + To: "0x8004A818BFB912233c491871b3d84c89A494BD9e", + Nonce: "0", + GasLimit: "100000", + MaxFeePerGas: "1000000000", + MaxPriorityFeePerGas: "1000000", + Value: "0", + Data: "0x", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if signed != "0x02f8deadbeef" { + t.Errorf("got %q, want 0x02f8deadbeef", signed) + } +} + +func TestRemoteSigner_SignTypedData(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + + var data EIP712TypedData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + t.Fatalf("decode request: %v", err) + } + if data.PrimaryType != "Registration" { + t.Errorf("primaryType = %q, want Registration", data.PrimaryType) + } + + json.NewEncoder(w).Encode(signResponse{ + Signature: "0xdeadbeef", + }) + })) + defer srv.Close() + + signer := NewRemoteSigner(srv.URL) + addr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + sig, err := signer.SignTypedData(context.Background(), addr, EIP712TypedData{ + Types: map[string][]EIP712Field{ + "Registration": {{Name: "agent", Type: "address"}}, + }, + PrimaryType: "Registration", + Domain: map[string]interface{}{"name": "test"}, + Message: map[string]interface{}{"agent": addr.Hex()}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sig != "0xdeadbeef" { + t.Errorf("got %q, want 0xdeadbeef", sig) + } +} + +func TestRemoteSigner_SignTransaction_Error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(signResponse{Error: "SIGNER_NOT_FOUND"}) + })) + defer srv.Close() + + signer := NewRemoteSigner(srv.URL) + addr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + _, err := signer.SignTransaction(context.Background(), addr, SignTxRequest{ChainID: "1"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestRemoteSigner_SignTransaction_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "internal error") + })) + defer srv.Close() + + signer := NewRemoteSigner(srv.URL) + addr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + _, err := signer.SignTransaction(context.Background(), addr, SignTxRequest{ChainID: "1"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("error should mention status code: %v", err) + } +} + +func TestRemoteSigner_SignTypedData_Error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(signResponse{Error: "UNSUPPORTED_TYPE"}) + })) + defer srv.Close() + + signer := NewRemoteSigner(srv.URL) + addr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + _, err := signer.SignTypedData(context.Background(), addr, EIP712TypedData{ + PrimaryType: "Test", + }) + if err == nil { + t.Fatal("expected error") + } +} + +func TestRemoteSigner_SignTypedData_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + fmt.Fprint(w, "bad gateway") + })) + defer srv.Close() + + signer := NewRemoteSigner(srv.URL) + addr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + _, err := signer.SignTypedData(context.Background(), addr, EIP712TypedData{ + PrimaryType: "Test", + }) + if err == nil { + t.Fatal("expected error for HTTP 502") + } + if !strings.Contains(err.Error(), "502") { + t.Errorf("error should mention status code: %v", err) + } +} + +func TestRemoteSigner_GetAddress_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + fmt.Fprint(w, "service unavailable") + })) + defer srv.Close() + + signer := NewRemoteSigner(srv.URL) + _, err := signer.GetAddress(context.Background()) + if err == nil { + t.Fatal("expected error for HTTP 503") + } + if !strings.Contains(err.Error(), "503") { + t.Errorf("error should mention status code: %v", err) + } +} + +func TestRemoteTransactOpts(t *testing.T) { + addr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + chainID := big.NewInt(84532) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // This verifies the signer receives proper requests. + if r.URL.Path == "/api/v1/keys" { + json.NewEncoder(w).Encode(keysResponse{Keys: []string{addr.Hex()}}) + return + } + // For transaction signing, return the error since we can't easily + // produce a valid signed tx in a unit test. + json.NewEncoder(w).Encode(signResponse{Error: "test: not implemented"}) + })) + defer srv.Close() + + signer := NewRemoteSigner(srv.URL) + opts := signer.RemoteTransactOpts(context.Background(), addr, chainID) + + if opts.From != addr { + t.Errorf("From = %s, want %s", opts.From.Hex(), addr.Hex()) + } + if opts.Signer == nil { + t.Fatal("Signer should not be nil") + } +} + +func TestHexToBytes(t *testing.T) { + tests := []struct { + input string + want int + err bool + }{ + {"0xdeadbeef", 4, false}, + {"deadbeef", 4, false}, + {"0x", 0, false}, + {"invalid", 0, true}, + } + for _, tt := range tests { + b, err := hexToBytes(tt.input) + if (err != nil) != tt.err { + t.Errorf("hexToBytes(%q) error = %v, wantErr %v", tt.input, err, tt.err) + } + if !tt.err && len(b) != tt.want { + t.Errorf("hexToBytes(%q) len = %d, want %d", tt.input, len(b), tt.want) + } + } +} diff --git a/internal/erc8004/sponsor.go b/internal/erc8004/sponsor.go new file mode 100644 index 00000000..bfb9a012 --- /dev/null +++ b/internal/erc8004/sponsor.go @@ -0,0 +1,244 @@ +package erc8004 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +// SponsoredRegisterRequest is the request body for the sponsored registration API. +type SponsoredRegisterRequest struct { + AgentAddress string `json:"agentAddress"` + AgentURI string `json:"agentURI"` + Deadline int64 `json:"deadline"` + IntentSignature string `json:"intentSignature"` + Authorization SponsorAuthorization `json:"authorization"` +} + +// SponsorAuthorization is the EIP-7702 authorization included in sponsored registration. +type SponsorAuthorization struct { + Address string `json:"address"` + ChainID int64 `json:"chainId"` + Nonce int64 `json:"nonce"` + R string `json:"r"` + S string `json:"s"` + YParity int `json:"yParity"` +} + +// SponsoredRegisterResponse is the response from the sponsored registration API. +type SponsoredRegisterResponse struct { + Success bool `json:"success"` + AgentID int64 `json:"agentId"` + TxHash string `json:"txHash"` + Error string `json:"error,omitempty"` +} + +// SponsoredRegister performs a zero-gas registration via the sponsor API. +// It signs the required EIP-712 messages using the remote-signer, then +// submits them to the sponsor endpoint which broadcasts the transaction. +func SponsoredRegister(ctx context.Context, signer *RemoteSigner, agentURI string, net NetworkConfig) (*big.Int, string, error) { + if !net.HasSponsor() { + return nil, "", fmt.Errorf("network %q does not support sponsored registration", net.Name) + } + + addr, err := signer.GetAddress(ctx) + if err != nil { + return nil, "", fmt.Errorf("get signing address: %w", err) + } + + deadline := time.Now().Add(time.Hour).Unix() + + // Sign the EIP-7702 authorization (delegates EOA to the registration contract). + authSig, err := signAuthorization(ctx, signer, addr, net) + if err != nil { + return nil, "", fmt.Errorf("sign authorization: %w", err) + } + + // Sign the registration intent. + intentSig, err := signRegistrationIntent(ctx, signer, addr, agentURI, deadline, net) + if err != nil { + return nil, "", fmt.Errorf("sign registration intent: %w", err) + } + + // Submit to sponsor. + reqBody := SponsoredRegisterRequest{ + AgentAddress: addr.Hex(), + AgentURI: agentURI, + Deadline: deadline, + IntentSignature: intentSig, + Authorization: authSig, + } + + result, err := postSponsor(ctx, signer.client, net.SponsorURL, reqBody) + if err != nil { + return nil, "", err + } + + return big.NewInt(result.AgentID), result.TxHash, nil +} + +// signAuthorization signs the EIP-7702 authorization to delegate the EOA to +// the registration delegate contract. +func signAuthorization(ctx context.Context, signer *RemoteSigner, addr common.Address, net NetworkConfig) (SponsorAuthorization, error) { + typedData := EIP712TypedData{ + Types: map[string][]EIP712Field{ + "EIP712Domain": { + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + }, + "Authorization": { + {Name: "address", Type: "address"}, + {Name: "chainId", Type: "uint256"}, + {Name: "nonce", Type: "uint256"}, + }, + }, + PrimaryType: "Authorization", + Domain: map[string]interface{}{ + "name": "ERC8004Registry", + "version": "1", + "chainId": net.ChainID, + }, + Message: map[string]interface{}{ + "address": net.DelegateAddress, + "chainId": net.ChainID, + "nonce": 0, + }, + } + + sig, err := signer.SignTypedData(ctx, addr, typedData) + if err != nil { + return SponsorAuthorization{}, err + } + + r, s, v, err := splitSignature(sig) + if err != nil { + return SponsorAuthorization{}, fmt.Errorf("split authorization signature: %w", err) + } + + return SponsorAuthorization{ + Address: net.DelegateAddress, + ChainID: net.ChainID, + Nonce: 0, + R: r, + S: s, + YParity: v, + }, nil +} + +// signRegistrationIntent signs the registration intent message. +func signRegistrationIntent(ctx context.Context, signer *RemoteSigner, addr common.Address, agentURI string, deadline int64, net NetworkConfig) (string, error) { + typedData := EIP712TypedData{ + Types: map[string][]EIP712Field{ + "EIP712Domain": { + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + {Name: "verifyingContract", Type: "address"}, + }, + "Register": { + {Name: "agentAddress", Type: "address"}, + {Name: "agentURI", Type: "string"}, + {Name: "deadline", Type: "uint256"}, + }, + }, + PrimaryType: "Register", + Domain: map[string]interface{}{ + "name": "ERC8004Registry", + "version": "1", + "chainId": net.ChainID, + "verifyingContract": net.RegistryAddress, + }, + Message: map[string]interface{}{ + "agentAddress": addr.Hex(), + "agentURI": agentURI, + "deadline": deadline, + }, + } + + return signer.SignTypedData(ctx, addr, typedData) +} + +// splitSignature splits a 65-byte hex signature into r, s, v components. +func splitSignature(sig string) (r, s string, v int, err error) { + // Remove 0x prefix if present. + hex := sig + if len(hex) >= 2 && hex[:2] == "0x" { + hex = hex[2:] + } + + if len(hex) != 130 { + return "", "", 0, fmt.Errorf("expected 130 hex chars (65 bytes), got %d", len(hex)) + } + + r = "0x" + hex[:64] + s = "0x" + hex[64:128] + + // v is the last byte; convert to yParity (0 or 1). + vByte := hex[128:130] + switch vByte { + case "00", "1b": + v = 0 + case "01", "1c": + v = 1 + default: + // Parse as decimal fallback. + var vInt int + if _, err := fmt.Sscanf(vByte, "%02x", &vInt); err != nil { + return "", "", 0, fmt.Errorf("unexpected v byte: %s", vByte) + } + if vInt >= 27 { + vInt -= 27 + } + v = vInt + } + + return r, s, v, nil +} + +// postSponsor submits the sponsored registration request to the sponsor API. +func postSponsor(ctx context.Context, client *http.Client, sponsorURL string, req SponsoredRegisterRequest) (*SponsoredRegisterResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal sponsor request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, sponsorURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build sponsor request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("sponsor request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read sponsor response: %w", err) + } + + var result SponsoredRegisterResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("decode sponsor response: %w (body: %s)", err, respBody) + } + + if !result.Success { + errMsg := result.Error + if errMsg == "" { + errMsg = string(respBody) + } + return nil, fmt.Errorf("sponsored registration failed: %s", errMsg) + } + + return &result, nil +} diff --git a/internal/erc8004/sponsor_test.go b/internal/erc8004/sponsor_test.go new file mode 100644 index 00000000..af495b98 --- /dev/null +++ b/internal/erc8004/sponsor_test.go @@ -0,0 +1,201 @@ +package erc8004 + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSplitSignature(t *testing.T) { + // 65-byte signature: 32 bytes r + 32 bytes s + 1 byte v + sig := "0x" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + // r (32 bytes) + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + // s (32 bytes) + "00" // v = 0 + + r, s, v, err := splitSignature(sig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if r != "0x"+"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { + t.Errorf("r mismatch") + } + if s != "0x"+"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" { + t.Errorf("s mismatch") + } + if v != 0 { + t.Errorf("v = %d, want 0", v) + } +} + +func TestSplitSignature_V27(t *testing.T) { + sig := "0x" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + "1b" // v = 27 → yParity 0 + + _, _, v, err := splitSignature(sig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != 0 { + t.Errorf("v = %d, want 0", v) + } +} + +func TestSplitSignature_V28(t *testing.T) { + sig := "0x" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + "1c" // v = 28 → yParity 1 + + _, _, v, err := splitSignature(sig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != 1 { + t.Errorf("v = %d, want 1", v) + } +} + +func TestSplitSignature_InvalidLength(t *testing.T) { + _, _, _, err := splitSignature("0xdeadbeef") + if err == nil { + t.Fatal("expected error for invalid length") + } +} + +func TestPostSponsor_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + + var req SponsoredRegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode: %v", err) + } + if req.AgentAddress != "0x1234" { + t.Errorf("agentAddress = %q, want 0x1234", req.AgentAddress) + } + + json.NewEncoder(w).Encode(SponsoredRegisterResponse{ + Success: true, + AgentID: 42, + TxHash: "0xabc", + }) + })) + defer srv.Close() + + result, err := postSponsor(context.Background(), http.DefaultClient, srv.URL, SponsoredRegisterRequest{ + AgentAddress: "0x1234", + AgentURI: "https://example.com/.well-known/agent-registration.json", + Deadline: 9999999999, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.AgentID != 42 { + t.Errorf("agentId = %d, want 42", result.AgentID) + } + if result.TxHash != "0xabc" { + t.Errorf("txHash = %q, want 0xabc", result.TxHash) + } +} + +func TestPostSponsor_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer srv.Close() + + _, err := postSponsor(context.Background(), http.DefaultClient, srv.URL, SponsoredRegisterRequest{}) + if err == nil { + t.Fatal("expected error for non-JSON 500 response") + } +} + +func TestSponsoredRegister_NoSponsor(t *testing.T) { + signer := NewRemoteSigner("http://unused") + _, _, err := SponsoredRegister(context.Background(), signer, "https://example.com", BaseSepolia) + if err == nil { + t.Fatal("expected error for unsupported sponsor") + } +} + +func TestSponsoredRegister_Integration(t *testing.T) { + // Mock both the remote-signer and sponsor APIs. + signerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/keys": + json.NewEncoder(w).Encode(keysResponse{ + Keys: []string{"0xAbCd1234567890abcdef1234567890abcdef1234"}, + }) + default: + // Return a 65-byte signature for any signing request. + sig := "0x" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + "1b" + json.NewEncoder(w).Encode(signResponse{Signature: sig}) + } + })) + defer signerSrv.Close() + + sponsorSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req SponsoredRegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode sponsor request: %v", err) + } + if req.AgentAddress == "" { + t.Error("expected non-empty agentAddress") + } + if req.IntentSignature == "" { + t.Error("expected non-empty intentSignature") + } + json.NewEncoder(w).Encode(SponsoredRegisterResponse{ + Success: true, + AgentID: 99, + TxHash: "0xdeadbeef", + }) + })) + defer sponsorSrv.Close() + + signer := NewRemoteSigner(signerSrv.URL) + net := NetworkConfig{ + Name: "test-sponsored", + ChainID: 1, + RegistryAddress: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + SponsorURL: sponsorSrv.URL, + DelegateAddress: "0x0000000000000000000000000000000000001234", + } + + agentID, txHash, err := SponsoredRegister(context.Background(), signer, "https://example.com/.well-known/agent-registration.json", net) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if agentID.Int64() != 99 { + t.Errorf("agentID = %d, want 99", agentID.Int64()) + } + if txHash != "0xdeadbeef" { + t.Errorf("txHash = %q, want 0xdeadbeef", txHash) + } +} + +func TestPostSponsor_Failure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(SponsoredRegisterResponse{ + Success: false, + Error: "insufficient funds", + }) + })) + defer srv.Close() + + _, err := postSponsor(context.Background(), http.DefaultClient, srv.URL, SponsoredRegisterRequest{}) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go index d1d105c9..c5100a91 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -1083,10 +1083,20 @@ func Dashboard(cfg *config.Config, id string, opts DashboardOptions, onReady fun } // List displays installed OpenClaw instances +// openclawInstance is the JSON-serialisable representation of one instance. +type openclawInstance struct { + ID string `json:"id"` + Namespace string `json:"namespace"` + URL string `json:"url"` +} + func List(cfg *config.Config, u *ui.UI) error { appsDir := filepath.Join(cfg.ConfigDir, "applications", appName) if _, err := os.Stat(appsDir); os.IsNotExist(err) { + if u.IsJSON() { + return u.JSON([]openclawInstance{}) + } u.Print("No OpenClaw instances installed") u.Print("\nTo create one: obol openclaw up") return nil @@ -1098,14 +1108,14 @@ func List(cfg *config.Config, u *ui.UI) error { } if len(entries) == 0 { + if u.IsJSON() { + return u.JSON([]openclawInstance{}) + } u.Print("No OpenClaw instances installed") return nil } - u.Info("OpenClaw instances:") - u.Blank() - - count := 0 + var instances []openclawInstance for _, entry := range entries { if !entry.IsDir() { continue @@ -1113,14 +1123,28 @@ func List(cfg *config.Config, u *ui.UI) error { id := entry.Name() namespace := fmt.Sprintf("%s-%s", appName, id) hostname := fmt.Sprintf("openclaw-%s.%s", id, defaultDomain) - u.Bold(" " + id) - u.Detail(" Namespace", namespace) - u.Detail(" URL", fmt.Sprintf("http://%s", hostname)) + instances = append(instances, openclawInstance{ + ID: id, + Namespace: namespace, + URL: fmt.Sprintf("http://%s", hostname), + }) + } + + if u.IsJSON() { + return u.JSON(instances) + } + + u.Info("OpenClaw instances:") + u.Blank() + + for _, inst := range instances { + u.Bold(" " + inst.ID) + u.Detail(" Namespace", inst.Namespace) + u.Detail(" URL", inst.URL) u.Blank() - count++ } - u.Printf("Total: %d instance(s)", count) + u.Printf("Total: %d instance(s)", len(instances)) return nil } diff --git a/internal/openclaw/wallet_resolve.go b/internal/openclaw/wallet_resolve.go new file mode 100644 index 00000000..69d19b2e --- /dev/null +++ b/internal/openclaw/wallet_resolve.go @@ -0,0 +1,65 @@ +package openclaw + +import ( + "fmt" + "strings" + + "github.com/ObolNetwork/obol-stack/internal/config" +) + +// ResolveWalletAddress returns the wallet address from the single OpenClaw +// instance's remote-signer. It follows the same convention as ResolveInstance: +// +// - 0 instances: error +// - 1 instance: returns its wallet address +// - 2+ instances: error listing available addresses +func ResolveWalletAddress(cfg *config.Config) (string, error) { + ids, err := ListInstanceIDs(cfg) + if err != nil { + return "", err + } + + switch len(ids) { + case 0: + return "", fmt.Errorf("no OpenClaw instances found — run 'obol agent init' first, or use --wallet") + case 1: + wallet, err := ReadWalletMetadata(DeploymentPath(cfg, ids[0])) + if err != nil { + return "", fmt.Errorf("wallet not found for instance %q: %w (use --wallet to specify manually)", ids[0], err) + } + return wallet.Address, nil + default: + var addrs []string + for _, id := range ids { + w, err := ReadWalletMetadata(DeploymentPath(cfg, id)) + if err != nil { + continue + } + addrs = append(addrs, fmt.Sprintf(" %s (instance: %s)", w.Address, id)) + } + return "", fmt.Errorf("multiple OpenClaw instances found, use --wallet to specify:\n%s", strings.Join(addrs, "\n")) + } +} + +// ResolveInstanceNamespace returns the Kubernetes namespace of the single +// OpenClaw instance. This is needed for port-forwarding to the remote-signer. +func ResolveInstanceNamespace(cfg *config.Config) (string, error) { + ids, err := ListInstanceIDs(cfg) + if err != nil { + return "", err + } + + switch len(ids) { + case 0: + return "", fmt.Errorf("no OpenClaw instances found — run 'obol agent init' first") + case 1: + return instanceNamespace(ids[0]), nil + default: + return "", fmt.Errorf("multiple OpenClaw instances found (%s), specify an instance", strings.Join(ids, ", ")) + } +} + +// instanceNamespace returns the Kubernetes namespace for a given instance ID. +func instanceNamespace(id string) string { + return fmt.Sprintf("openclaw-%s", id) +} diff --git a/internal/openclaw/wallet_resolve_test.go b/internal/openclaw/wallet_resolve_test.go new file mode 100644 index 00000000..d598ea85 --- /dev/null +++ b/internal/openclaw/wallet_resolve_test.go @@ -0,0 +1,120 @@ +package openclaw + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/config" +) + +func TestResolveWalletAddress_NoInstances(t *testing.T) { + cfg := &config.Config{ConfigDir: t.TempDir()} + _, err := ResolveWalletAddress(cfg) + if err == nil { + t.Fatal("expected error for no instances") + } +} + +func TestResolveWalletAddress_SingleInstance(t *testing.T) { + cfg := &config.Config{ConfigDir: t.TempDir()} + + // Create instance directory structure. + instDir := filepath.Join(cfg.ConfigDir, "applications", appName, "test-instance") + if err := os.MkdirAll(instDir, 0755); err != nil { + t.Fatal(err) + } + + wallet := &WalletInfo{ + Address: "0xAbCd1234567890abcdef1234567890abcdef1234", + KeystoreUUID: "uuid-123", + } + if err := WriteWalletMetadata(instDir, wallet); err != nil { + t.Fatal(err) + } + + addr, err := ResolveWalletAddress(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if addr != wallet.Address { + t.Errorf("got %q, want %q", addr, wallet.Address) + } +} + +func TestResolveWalletAddress_MultipleInstances(t *testing.T) { + cfg := &config.Config{ConfigDir: t.TempDir()} + appsDir := filepath.Join(cfg.ConfigDir, "applications", appName) + + for _, id := range []string{"inst-a", "inst-b"} { + instDir := filepath.Join(appsDir, id) + if err := os.MkdirAll(instDir, 0755); err != nil { + t.Fatal(err) + } + wallet := &WalletInfo{Address: "0x" + id} + if err := WriteWalletMetadata(instDir, wallet); err != nil { + t.Fatal(err) + } + } + + _, err := ResolveWalletAddress(cfg) + if err == nil { + t.Fatal("expected error for multiple instances") + } +} + +func TestResolveWalletAddress_CorruptedWallet(t *testing.T) { + cfg := &config.Config{ConfigDir: t.TempDir()} + instDir := filepath.Join(cfg.ConfigDir, "applications", appName, "broken-instance") + if err := os.MkdirAll(instDir, 0755); err != nil { + t.Fatal(err) + } + // Write invalid JSON as wallet metadata. + if err := os.WriteFile(filepath.Join(instDir, "wallet.json"), []byte("not json"), 0644); err != nil { + t.Fatal(err) + } + + _, err := ResolveWalletAddress(cfg) + if err == nil { + t.Fatal("expected error for corrupted wallet.json") + } +} + +func TestResolveInstanceNamespace_NoInstances(t *testing.T) { + cfg := &config.Config{ConfigDir: t.TempDir()} + _, err := ResolveInstanceNamespace(cfg) + if err == nil { + t.Fatal("expected error for no instances") + } +} + +func TestResolveInstanceNamespace_MultipleInstances(t *testing.T) { + cfg := &config.Config{ConfigDir: t.TempDir()} + appsDir := filepath.Join(cfg.ConfigDir, "applications", appName) + for _, id := range []string{"inst-x", "inst-y"} { + if err := os.MkdirAll(filepath.Join(appsDir, id), 0755); err != nil { + t.Fatal(err) + } + } + + _, err := ResolveInstanceNamespace(cfg) + if err == nil { + t.Fatal("expected error for multiple instances") + } +} + +func TestResolveInstanceNamespace_SingleInstance(t *testing.T) { + cfg := &config.Config{ConfigDir: t.TempDir()} + instDir := filepath.Join(cfg.ConfigDir, "applications", appName, "my-agent") + if err := os.MkdirAll(instDir, 0755); err != nil { + t.Fatal(err) + } + + ns, err := ResolveInstanceNamespace(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ns != "openclaw-my-agent" { + t.Errorf("got %q, want %q", ns, "openclaw-my-agent") + } +} diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 4927c048..a3bc7d4b 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -24,6 +24,15 @@ const ( tunnelTokenSecretKey = "TUNNEL_TOKEN" ) +// Status displays the current tunnel status and URL. +// tunnelStatusResult is the JSON-serialisable result for `tunnel status`. +type tunnelStatusResult struct { + Mode string `json:"mode"` + Status string `json:"status"` + URL string `json:"url"` + LastUpdated string `json:"last_updated"` +} + // Status displays the current tunnel status and URL. func Status(cfg *config.Config, u *ui.UI) error { kubectlPath := filepath.Join(cfg.BinDir, "kubectl") @@ -42,6 +51,9 @@ func Status(cfg *config.Config, u *ui.UI) error { mode, url := tunnelModeAndURL(st) if mode == "quick" { // Quick tunnel is dormant — activates on first `obol sell`. + if u.IsJSON() { + return u.JSON(tunnelStatusResult{Mode: "quick", Status: "dormant", URL: "(activates on 'obol sell')", LastUpdated: time.Now().Format(time.RFC3339)}) + } printStatusBox(u, "quick", "dormant", "(activates on 'obol sell')", time.Now()) u.Blank() u.Print("The tunnel will start automatically when you sell a service.") @@ -49,6 +61,9 @@ func Status(cfg *config.Config, u *ui.UI) error { u.Print(" Persistent URL: obol tunnel login --hostname stack.example.com") return nil } + if u.IsJSON() { + return u.JSON(tunnelStatusResult{Mode: mode, Status: "not running", URL: url, LastUpdated: time.Now().Format(time.RFC3339)}) + } printStatusBox(u, mode, "not running", url, time.Now()) u.Blank() u.Print("Troubleshooting:") @@ -66,6 +81,9 @@ func Status(cfg *config.Config, u *ui.UI) error { // Quick tunnels only: try to get URL from logs. tunnelURL, err := GetTunnelURL(cfg) if err != nil { + if u.IsJSON() { + return u.JSON(tunnelStatusResult{Mode: mode, Status: podStatus, URL: "(not available)", LastUpdated: time.Now().Format(time.RFC3339)}) + } printStatusBox(u, mode, podStatus, "(not available)", time.Now()) u.Blank() u.Print("Troubleshooting:") @@ -76,6 +94,10 @@ func Status(cfg *config.Config, u *ui.UI) error { url = tunnelURL } + if u.IsJSON() { + return u.JSON(tunnelStatusResult{Mode: mode, Status: statusLabel, URL: url, LastUpdated: time.Now().Format(time.RFC3339)}) + } + printStatusBox(u, mode, statusLabel, url, time.Now()) u.Printf("Test with: curl %s/", url) diff --git a/internal/ui/prompt.go b/internal/ui/prompt.go index 4eb51a60..e78db6d2 100644 --- a/internal/ui/prompt.go +++ b/internal/ui/prompt.go @@ -10,9 +10,19 @@ import ( "golang.org/x/term" ) +// isInteractive returns true if prompts should be shown. +// Returns false in JSON mode or when stdin is not a terminal. +func (u *UI) isInteractive() bool { + return !u.IsJSON() && u.IsTTY() +} + // Confirm asks a yes/no question, returns true for "y"/"yes". -// The default is shown in brackets: [Y/n] or [y/N]. +// In non-interactive mode, returns the default without prompting. func (u *UI) Confirm(msg string, defaultYes bool) bool { + if !u.isInteractive() { + return defaultYes + } + suffix := "[y/N]" if defaultYes { suffix = "[Y/n]" @@ -30,8 +40,12 @@ func (u *UI) Confirm(msg string, defaultYes bool) bool { } // Select presents a numbered list and returns the selected index. -// defaultIdx is 0-based; shown as [default] next to that option. +// In non-interactive mode, returns the default index without prompting. func (u *UI) Select(msg string, options []string, defaultIdx int) (int, error) { + if !u.isInteractive() { + return defaultIdx, nil + } + fmt.Fprintln(u.stdout, msg) for i, opt := range options { marker := " " @@ -64,7 +78,15 @@ func (u *UI) Select(msg string, options []string, defaultIdx int) (int, error) { } // Input reads a single line of text input with an optional default. +// In non-interactive mode, returns the default or an error if no default is set. func (u *UI) Input(msg string, defaultVal string) (string, error) { + if !u.isInteractive() { + if defaultVal != "" { + return defaultVal, nil + } + return "", fmt.Errorf("%s: required (non-interactive mode, provide via flag)", msg) + } + if defaultVal != "" { fmt.Fprintf(u.stdout, "%s %s: ", msg, dimStyle.Render("["+defaultVal+"]")) } else { @@ -84,7 +106,12 @@ func (u *UI) Input(msg string, defaultVal string) (string, error) { } // SecretInput reads input without echoing (for API keys, passwords). +// In non-interactive mode, returns an error directing the user to use a flag. func (u *UI) SecretInput(msg string) (string, error) { + if !u.isInteractive() { + return "", fmt.Errorf("%s: interactive input unavailable (provide via flag or env var)", msg) + } + fmt.Fprintf(u.stdout, "%s: ", msg) b, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Fprintln(u.stdout) // newline after hidden input diff --git a/internal/ui/spinner.go b/internal/ui/spinner.go index 731b6142..678fb59e 100644 --- a/internal/ui/spinner.go +++ b/internal/ui/spinner.go @@ -16,7 +16,7 @@ var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", " func (u *UI) RunWithSpinner(msg string, fn func() error) error { start := time.Now() - if !u.isTTY || u.verbose { + if !u.isTTY || u.verbose || u.IsJSON() { u.Info(msg) err := fn() elapsed := time.Since(start).Round(time.Second) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index c1b86753..da0c28a0 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -3,9 +3,15 @@ // Pass a *UI through your call chain instead of using fmt directly. // Output adapts to the environment: colors and spinners in interactive // terminals, plain text when piped or in CI. +// +// When OutputJSON mode is active, human-readable output (Info, Success, Print, +// etc.) is redirected to stderr so that stdout contains only clean JSON. Use +// the JSON() method to emit structured data. package ui import ( + "encoding/json" + "fmt" "io" "os" "sync" @@ -13,11 +19,34 @@ import ( "github.com/mattn/go-isatty" ) +// OutputMode controls the format of command output. +type OutputMode int + +const ( + // OutputHuman is the default human-readable output mode. + OutputHuman OutputMode = iota + // OutputJSON produces machine-readable JSON on stdout. + OutputJSON +) + +// ParseOutputMode converts a string to an OutputMode. +func ParseOutputMode(s string) (OutputMode, error) { + switch s { + case "", "human": + return OutputHuman, nil + case "json": + return OutputJSON, nil + default: + return OutputHuman, fmt.Errorf("invalid output mode %q (use: human, json)", s) + } +} + // UI provides consistent terminal output primitives. type UI struct { verbose bool quiet bool isTTY bool + output OutputMode stdout io.Writer stderr io.Writer mu sync.Mutex @@ -42,6 +71,17 @@ func NewWithOptions(verbose, quiet bool) *UI { return u } +// NewWithAllOptions creates a UI instance with full control over all modes. +func NewWithAllOptions(verbose, quiet bool, output OutputMode) *UI { + u := NewWithOptions(verbose, quiet) + u.output = output + if output == OutputJSON { + // In JSON mode, redirect human output to stderr so stdout is clean JSON. + u.stdout = os.Stderr + } + return u +} + // IsVerbose returns whether verbose mode is enabled. func (u *UI) IsVerbose() bool { return u.verbose } @@ -50,3 +90,14 @@ func (u *UI) IsQuiet() bool { return u.quiet } // IsTTY returns whether stdout is an interactive terminal. func (u *UI) IsTTY() bool { return u.isTTY } + +// IsJSON returns whether JSON output mode is active. +func (u *UI) IsJSON() bool { return u.output == OutputJSON } + +// JSON writes v as indented JSON to the real stdout (os.Stdout). +// This bypasses the stderr redirect so agents always get JSON on stdout. +func (u *UI) JSON(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go new file mode 100644 index 00000000..0bbebd8f --- /dev/null +++ b/internal/validate/validate.go @@ -0,0 +1,134 @@ +// Package validate provides input validation functions for the obol CLI. +// Each function returns nil on success or a descriptive error. +package validate + +import ( + "fmt" + "net/url" + "regexp" + "strconv" + "strings" +) + +// nameRegex matches k8s-safe DNS labels: starts with lowercase alphanumeric, +// then lowercase alphanumeric or hyphens, max 63 chars. +var nameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + +// Name validates a resource name (k8s-safe DNS label). +func Name(s string) error { + if s == "" { + return fmt.Errorf("name cannot be empty") + } + if !nameRegex.MatchString(s) { + return fmt.Errorf("invalid name %q: must be lowercase alphanumeric with hyphens, 1-63 chars, starting with a letter or digit", s) + } + return nil +} + +// Namespace validates a Kubernetes namespace (same rules as Name). +func Namespace(s string) error { + if err := Name(s); err != nil { + return fmt.Errorf("invalid namespace: %w", err) + } + return nil +} + +// WalletAddress validates an Ethereum wallet address (0x-prefixed, 42 hex chars). +func WalletAddress(s string) error { + if s == "" { + return fmt.Errorf("wallet address cannot be empty") + } + if !strings.HasPrefix(s, "0x") { + return fmt.Errorf("wallet address must start with 0x: %q", s) + } + if len(s) != 42 { + return fmt.Errorf("wallet address must be 42 characters (got %d): %q", len(s), s) + } + for _, c := range s[2:] { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return fmt.Errorf("wallet address contains invalid hex character: %q", s) + } + } + return nil +} + +// knownChains is the set of valid chain names for x402 and ERC-8004. +var knownChains = map[string]bool{ + "base": true, + "base-mainnet": true, + "base-sepolia": true, + "ethereum": true, + "ethereum-mainnet": true, + "mainnet": true, +} + +// ChainName validates a blockchain chain name. +func ChainName(s string) error { + if s == "" { + return fmt.Errorf("chain name cannot be empty") + } + if !knownChains[strings.ToLower(s)] { + return fmt.Errorf("unknown chain %q (supported: base-sepolia, base, ethereum)", s) + } + return nil +} + +// Price validates a decimal price string (positive, parseable as float). +func Price(s string) error { + if s == "" { + return fmt.Errorf("price cannot be empty") + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return fmt.Errorf("invalid price %q: %w", s, err) + } + if f < 0 { + return fmt.Errorf("price must be non-negative: %q", s) + } + return nil +} + +// URL validates a URL string. +func URL(s string) error { + if s == "" { + return fmt.Errorf("URL cannot be empty") + } + u, err := url.Parse(s) + if err != nil { + return fmt.Errorf("invalid URL %q: %w", s, err) + } + if u.Scheme == "" { + return fmt.Errorf("URL missing scheme (http/https): %q", s) + } + if u.Host == "" { + return fmt.Errorf("URL missing host: %q", s) + } + return nil +} + +// Path validates a URL path segment (no path traversal, no control chars). +func Path(s string) error { + if s == "" { + return nil // empty path is valid + } + if strings.Contains(s, "..") { + return fmt.Errorf("path must not contain '..': %q", s) + } + if strings.Contains(s, "%2e") || strings.Contains(s, "%2E") { + return fmt.Errorf("path must not contain encoded traversal: %q", s) + } + if err := NoControlChars(s); err != nil { + return fmt.Errorf("invalid path: %w", err) + } + return nil +} + +// NoControlChars rejects strings containing control characters (except \n and \t). +func NoControlChars(s string) error { + for i, c := range s { + if c < 0x20 && c != '\n' && c != '\t' { + return fmt.Errorf("contains control character at position %d (0x%02x)", i, c) + } + } + return nil +} diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go new file mode 100644 index 00000000..5421206a --- /dev/null +++ b/internal/validate/validate_test.go @@ -0,0 +1,134 @@ +package validate + +import ( + "testing" +) + +func TestName(t *testing.T) { + valid := []string{"my-service", "a", "abc123", "test-inference-1"} + for _, s := range valid { + if err := Name(s); err != nil { + t.Errorf("Name(%q) = %v, want nil", s, err) + } + } + + invalid := []string{ + "", + "MyService", // uppercase + "-leading-hyphen", // starts with hyphen + "has spaces", + "has_underscore", + "../etc/passwd", // path traversal + "a" + string(make([]byte, 63)), // too long (64 chars) + } + for _, s := range invalid { + if err := Name(s); err == nil { + t.Errorf("Name(%q) = nil, want error", s) + } + } +} + +func TestNamespace(t *testing.T) { + if err := Namespace("default"); err != nil { + t.Errorf("Namespace(default) = %v", err) + } + if err := Namespace(""); err == nil { + t.Error("Namespace('') should error") + } +} + +func TestWalletAddress(t *testing.T) { + valid := "0xAbCd1234567890abcdef1234567890abcdef1234" + if err := WalletAddress(valid); err != nil { + t.Errorf("WalletAddress(%q) = %v", valid, err) + } + + invalid := []string{ + "", + "not-a-wallet", + "AbCd1234567890abcdef1234567890abcdef1234", // no 0x + "0xshort", + "0xZZZZ1234567890abcdef1234567890abcdef1234", // invalid hex + } + for _, s := range invalid { + if err := WalletAddress(s); err == nil { + t.Errorf("WalletAddress(%q) = nil, want error", s) + } + } +} + +func TestChainName(t *testing.T) { + valid := []string{"base-sepolia", "base", "ethereum", "mainnet", "base-mainnet"} + for _, s := range valid { + if err := ChainName(s); err != nil { + t.Errorf("ChainName(%q) = %v", s, err) + } + } + + invalid := []string{"", "polygon", "unknown", "solana"} + for _, s := range invalid { + if err := ChainName(s); err == nil { + t.Errorf("ChainName(%q) = nil, want error", s) + } + } +} + +func TestPrice(t *testing.T) { + valid := []string{"0", "0.001", "1.5", "100"} + for _, s := range valid { + if err := Price(s); err != nil { + t.Errorf("Price(%q) = %v", s, err) + } + } + + invalid := []string{"", "abc", "-1"} + for _, s := range invalid { + if err := Price(s); err == nil { + t.Errorf("Price(%q) = nil, want error", s) + } + } +} + +func TestURL(t *testing.T) { + valid := []string{"http://localhost:8080", "https://example.com/path"} + for _, s := range valid { + if err := URL(s); err != nil { + t.Errorf("URL(%q) = %v", s, err) + } + } + + invalid := []string{"", "not-a-url", "/just/a/path"} + for _, s := range invalid { + if err := URL(s); err == nil { + t.Errorf("URL(%q) = nil, want error", s) + } + } +} + +func TestPath(t *testing.T) { + valid := []string{"", "/services/my-api", "/rpc/mainnet"} + for _, s := range valid { + if err := Path(s); err != nil { + t.Errorf("Path(%q) = %v", s, err) + } + } + + invalid := []string{"../etc/passwd", "/foo/%2e%2e/bar", "/has\x00null"} + for _, s := range invalid { + if err := Path(s); err == nil { + t.Errorf("Path(%q) = nil, want error", s) + } + } +} + +func TestNoControlChars(t *testing.T) { + if err := NoControlChars("normal text\nwith\ttabs"); err != nil { + t.Errorf("should allow newlines and tabs: %v", err) + } + if err := NoControlChars("has\x00null"); err == nil { + t.Error("should reject null byte") + } + if err := NoControlChars("has\x01soh"); err == nil { + t.Error("should reject SOH") + } +} diff --git a/internal/x402/config.go b/internal/x402/config.go index b82bfdac..f5b561dd 100644 --- a/internal/x402/config.go +++ b/internal/x402/config.go @@ -132,6 +132,15 @@ func ValidateFacilitatorURL(u string) error { return fmt.Errorf("facilitator URL must use HTTPS (except localhost): %q", u) } +// EthereumMainnet is the x402 ChainConfig for Ethereum mainnet USDC. +var EthereumMainnet = x402lib.ChainConfig{ + NetworkID: "ethereum", + USDCAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + Decimals: 6, + EIP3009Name: "USD Coin", + EIP3009Version: "2", +} + // ResolveChain maps a chain name string to an x402 ChainConfig. func ResolveChain(name string) (x402lib.ChainConfig, error) { switch name { @@ -139,6 +148,8 @@ func ResolveChain(name string) (x402lib.ChainConfig, error) { return x402lib.BaseMainnet, nil case "base-sepolia": return x402lib.BaseSepolia, nil + case "ethereum", "ethereum-mainnet", "mainnet": + return EthereumMainnet, nil case "polygon", "polygon-mainnet": return x402lib.PolygonMainnet, nil case "polygon-amoy": @@ -148,6 +159,6 @@ func ResolveChain(name string) (x402lib.ChainConfig, error) { case "avalanche-fuji": return x402lib.AvalancheFuji, nil default: - return x402lib.ChainConfig{}, fmt.Errorf("unsupported chain: %s (use: base, base-sepolia, polygon, polygon-amoy, avalanche, avalanche-fuji)", name) + return x402lib.ChainConfig{}, fmt.Errorf("unsupported chain: %s (use: base, base-sepolia, ethereum, polygon, polygon-amoy, avalanche, avalanche-fuji)", name) } } diff --git a/internal/x402/config_test.go b/internal/x402/config_test.go index e58e82ba..2698dc47 100644 --- a/internal/x402/config_test.go +++ b/internal/x402/config_test.go @@ -114,6 +114,7 @@ func TestResolveChain_AllSupported(t *testing.T) { }{ {"base-sepolia", x402lib.BaseSepolia}, {"base", x402lib.BaseMainnet}, + {"ethereum", EthereumMainnet}, {"polygon", x402lib.PolygonMainnet}, {"polygon-amoy", x402lib.PolygonAmoy}, {"avalanche", x402lib.AvalancheMainnet}, @@ -139,6 +140,8 @@ func TestResolveChain_Aliases(t *testing.T) { canonical string }{ {"base-mainnet", "base"}, + {"ethereum-mainnet", "ethereum"}, + {"mainnet", "ethereum"}, {"polygon-mainnet", "polygon"}, {"avalanche-mainnet", "avalanche"}, } @@ -162,7 +165,7 @@ func TestResolveChain_Aliases(t *testing.T) { } func TestResolveChain_Unsupported(t *testing.T) { - unsupported := []string{"ethereum", "mainnet", "solana", "unknown-chain", ""} + unsupported := []string{"solana", "unknown-chain", ""} for _, name := range unsupported { t.Run(name, func(t *testing.T) { _, err := ResolveChain(name)