From 37a7274b62619f9e75979596e907a34df80eae65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Tue, 17 Mar 2026 00:36:10 +0000 Subject: [PATCH 01/14] Delete duplicate openclaw. stop tunnelling ui to public internet. make sale UI. needs frontend pr too. --- cmd/obol/main.go | 7 + cmd/obol/sell.go | 205 +++++++++++++- internal/agent/agent.go | 57 +--- .../templates/obol-agent-monetize-rbac.yaml | 12 +- internal/embed/infrastructure/helmfile.yaml | 6 + internal/inference/gateway.go | 11 +- internal/inference/store.go | 4 + .../openclaw/monetize_integration_test.go | 11 +- internal/openclaw/openclaw.go | 6 +- internal/stack/stack.go | 28 +- internal/tunnel/agent.go | 2 +- internal/tunnel/tunnel.go | 251 +++++++++++++++++- internal/x402/bdd_integration_test.go | 6 +- internal/x402/setup.go | 2 +- 14 files changed, 526 insertions(+), 82 deletions(-) diff --git a/cmd/obol/main.go b/cmd/obol/main.go index af92301a..e8d80385 100644 --- a/cmd/obol/main.go +++ b/cmd/obol/main.go @@ -278,6 +278,13 @@ GLOBAL OPTIONS:{{template "visibleFlagTemplate" .}}{{end}} return tunnel.Restart(cfg, getUI(cmd)) }, }, + { + Name: "stop", + Usage: "Stop the tunnel (scale cloudflared to 0 replicas)", + Action: func(ctx context.Context, cmd *cli.Command) error { + return tunnel.Stop(cfg, getUI(cmd)) + }, + }, { Name: "logs", Usage: "View cloudflared logs", diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 1e99ebc6..54452d66 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -5,8 +5,10 @@ import ( "encoding/hex" "encoding/json" "fmt" + "net" "os" "os/signal" + "runtime" "strings" "syscall" @@ -210,6 +212,63 @@ Examples: return err } + // If a cluster is available, route through the cluster's x402 flow + // (tunnel → Traefik → x402-verifier → host gateway → Ollama). + // The gateway's built-in x402 is disabled to avoid double-gating. + kubeconfigPath := fmt.Sprintf("%s/kubeconfig.yaml", cfg.ConfigDir) + clusterAvailable := false + if _, statErr := os.Stat(kubeconfigPath); statErr == nil { + clusterAvailable = true + } + + if clusterAvailable { + d.NoPaymentGate = true + + // Resolve the gateway port from the listen address. + listenAddr := d.ListenAddr + port := "8402" + if idx := strings.LastIndex(listenAddr, ":"); idx >= 0 { + port = listenAddr[idx+1:] + } + + // Create a K8s Service + Endpoints pointing to the host. + svcNs := "llm" // co-locate with LiteLLM for simplicity + if err := createHostService(cfg, name, svcNs, port); err != nil { + fmt.Printf("Warning: could not create cluster service: %v\n", err) + fmt.Println("Falling back to standalone mode with built-in x402 payment gate.") + d.NoPaymentGate = false + } else { + // Create a ServiceOffer CR pointing at the host service. + soSpec := buildInferenceServiceOfferSpec(d, priceTable, svcNs, port) + soManifest := map[string]interface{}{ + "apiVersion": "obol.org/v1alpha1", + "kind": "ServiceOffer", + "metadata": map[string]interface{}{ + "name": name, + "namespace": svcNs, + }, + "spec": soSpec, + } + if err := kubectlApply(cfg, soManifest); err != nil { + fmt.Printf("Warning: could not create ServiceOffer: %v\n", err) + d.NoPaymentGate = false + } else { + fmt.Printf("ServiceOffer %s/%s created (type: inference, routed via cluster)\n", svcNs, name) + + // Ensure tunnel is active. + u := getUI(cmd) + u.Blank() + u.Info("Ensuring tunnel is active for public access...") + if tunnelURL, tErr := tunnel.EnsureTunnelForSell(cfg, u); tErr != nil { + u.Warnf("Tunnel not started: %v", tErr) + u.Dim(" Start manually with: obol tunnel restart") + } else { + u.Successf("Tunnel active: %s", tunnelURL) + } + } + } + } + return runInferenceGateway(d, chain) }, } @@ -397,6 +456,17 @@ Examples: } fmt.Printf("The agent will reconcile: health-check → payment gate → route\n") fmt.Printf("Check status: obol sell status %s -n %s\n", name, ns) + + // Ensure tunnel is active for public access. + u := getUI(cmd) + u.Blank() + u.Info("Ensuring tunnel is active for public access...") + if tunnelURL, err := tunnel.EnsureTunnelForSell(cfg, u); err != nil { + u.Warnf("Tunnel not started: %v", err) + u.Dim(" Start manually with: obol tunnel restart") + } else { + u.Successf("Tunnel active: %s", tunnelURL) + } return nil }, } @@ -621,7 +691,24 @@ func sellDeleteCommand(cfg *config.Config) *cli.Command { } } - return kubectlRun(cfg, "delete", "serviceoffers.obol.org", name, "-n", ns) + if err := kubectlRun(cfg, "delete", "serviceoffers.obol.org", name, "-n", ns); err != nil { + return err + } + + // Auto-stop quick tunnel when no ServiceOffers remain. + remaining, listErr := kubectlOutput(cfg, "get", "serviceoffers.obol.org", "-A", + "-o", "jsonpath={.items}") + if listErr == nil && (remaining == "[]" || strings.TrimSpace(remaining) == "") { + st, _ := tunnel.LoadTunnelState(cfg) + if st == nil || st.Mode != "dns" { + u := getUI(cmd) + u.Blank() + u.Info("No ServiceOffers remaining. Stopping quick tunnel.") + _ = tunnel.Stop(cfg, u) + _ = tunnel.DeleteStorefront(cfg) + } + } + return nil }, } } @@ -791,6 +878,7 @@ func runInferenceGateway(d *inference.Deployment, chain x402.ChainConfig) error VMHostPort: d.VMHostPort, TEEType: d.TEEType, ModelHash: d.ModelHash, + NoPaymentGate: d.NoPaymentGate, }) if err != nil { return fmt.Errorf("failed to create gateway: %w", err) @@ -1024,6 +1112,121 @@ func formatInferencePriceSummary(d *inference.Deployment) string { return fmt.Sprintf("%s USDC/request", d.PricePerRequest) } +// createHostService creates a headless Service + Endpoints in the cluster +// pointing to the Docker host IP on the given port, so that the cluster can +// route traffic to a host-side inference gateway. +// +// Kubernetes Endpoints require an IP address, not a hostname. We resolve the +// host IP using the same strategy as ollamaHostIPForBackend in internal/stack. +func createHostService(cfg *config.Config, name, ns, port string) error { + hostIP, err := resolveHostIP() + if err != nil { + return fmt.Errorf("cannot resolve host IP for cluster routing: %w", err) + } + + svc := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": name, + "namespace": ns, + }, + "spec": map[string]interface{}{ + "ports": []map[string]interface{}{ + {"port": 8402, "targetPort": 8402, "protocol": "TCP"}, + }, + }, + } + ep := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Endpoints", + "metadata": map[string]interface{}{ + "name": name, + "namespace": ns, + }, + "subsets": []map[string]interface{}{ + { + "addresses": []map[string]interface{}{ + {"ip": hostIP}, + }, + "ports": []map[string]interface{}{ + {"port": 8402, "protocol": "TCP"}, + }, + }, + }, + } + + if err := kubectlApply(cfg, svc); err != nil { + return fmt.Errorf("failed to create service: %w", err) + } + if err := kubectlApply(cfg, ep); err != nil { + return fmt.Errorf("failed to create endpoints: %w", err) + } + return nil +} + +// resolveHostIP returns the Docker host IP reachable from k3d containers. +// Same resolution strategy as stack.ollamaHostIPForBackend. +func resolveHostIP() (string, error) { + // Try DNS resolution of host.docker.internal (macOS) or host.k3d.internal (Linux). + for _, host := range []string{"host.docker.internal", "host.k3d.internal"} { + if addrs, err := net.LookupHost(host); err == nil && len(addrs) > 0 { + return addrs[0], nil + } + } + // macOS Docker Desktop fallback: well-known VM gateway. + if runtime.GOOS == "darwin" { + return "192.168.65.254", nil + } + // Linux fallback: docker0 bridge IP. + if iface, err := net.InterfaceByName("docker0"); err == nil { + if addrs, err := iface.Addrs(); err == nil { + for _, addr := range addrs { + if ipNet, ok := addr.(*net.IPNet); ok && ipNet.IP.To4() != nil { + return ipNet.IP.String(), nil + } + } + } + } + return "", fmt.Errorf("cannot determine Docker host IP; ensure Docker is running") +} + +// buildInferenceServiceOfferSpec builds a ServiceOffer spec for a host-side +// inference gateway routed through the cluster's x402 flow. +func buildInferenceServiceOfferSpec(d *inference.Deployment, pt schemas.PriceTable, ns, port string) map[string]interface{} { + spec := map[string]interface{}{ + "type": "inference", + "upstream": map[string]interface{}{ + "service": d.Name, + "namespace": ns, + "port": 8402, + "healthPath": "/health", + }, + "payment": map[string]interface{}{ + "scheme": "exact", + "network": d.Chain, + "payTo": d.WalletAddress, + "price": map[string]interface{}{}, + }, + "path": fmt.Sprintf("/services/%s", d.Name), + } + + price := spec["payment"].(map[string]interface{})["price"].(map[string]interface{}) + if pt.PerMTok != "" { + price["perMTok"] = pt.PerMTok + } else { + price["perRequest"] = d.PricePerRequest + } + + if d.UpstreamURL != "" { + spec["model"] = map[string]interface{}{ + "name": "ollama", + "runtime": "ollama", + } + } + return spec +} + // removePricingRoute removes the x402-verifier pricing route for the given offer. func removePricingRoute(cfg *config.Config, name string) { urlPath := fmt.Sprintf("/services/%s", name) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 01d6ca0c..63f8ae94 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -8,49 +8,19 @@ import ( "github.com/ObolNetwork/obol-stack/internal/config" "github.com/ObolNetwork/obol-stack/internal/kubectl" - "github.com/ObolNetwork/obol-stack/internal/openclaw" "github.com/ObolNetwork/obol-stack/internal/ui" ) -const agentID = "obol-agent" +// DefaultInstanceID is the canonical OpenClaw instance that runs both +// user-facing inference and agent-mode monetize/heartbeat reconciliation. +const DefaultInstanceID = "default" -// Init sets up the singleton obol-agent OpenClaw instance. -// It enforces a single agent by using a fixed deployment ID. -// After onboarding, it patches the monetize RBAC bindings -// to grant the agent's ServiceAccount monetization permissions, -// and injects HEARTBEAT.md to drive periodic reconciliation. +// Init patches the default OpenClaw instance with agent capabilities: +// monetize RBAC bindings and HEARTBEAT.md for periodic reconciliation. +// The actual OpenClaw deployment is created by openclaw.SetupDefault() +// during `obol stack up`; Init() adds the agent superpowers on top. func Init(cfg *config.Config, u *ui.UI) error { - // Check if obol-agent already exists. - instances, err := openclaw.ListInstanceIDs(cfg) - if err != nil { - return fmt.Errorf("failed to list OpenClaw instances: %w", err) - } - - exists := false - for _, id := range instances { - if id == agentID { - exists = true - break - } - } - - opts := openclaw.OnboardOptions{ - ID: agentID, - Sync: true, - Interactive: true, - AgentMode: true, - } - - if exists { - u.Warn("obol-agent already exists, re-syncing...") - opts.Force = true - } - - if err := openclaw.Onboard(cfg, opts, u); err != nil { - return fmt.Errorf("failed to onboard obol-agent: %w", err) - } - - // Patch ClusterRoleBinding to add the agent's ServiceAccount. + // Patch ClusterRoleBinding to add the default instance's ServiceAccount. if err := patchMonetizeBinding(cfg, u); err != nil { return fmt.Errorf("failed to patch ClusterRoleBinding: %w", err) } @@ -60,12 +30,11 @@ func Init(cfg *config.Config, u *ui.UI) error { return fmt.Errorf("failed to inject HEARTBEAT.md: %w", err) } - u.Print("") - u.Success("Agent initialized. To reconfigure, you can safely re-run: obol agent init") + u.Success("Agent capabilities applied to default OpenClaw instance") return nil } -// patchMonetizeBinding adds the obol-agent's OpenClaw ServiceAccount +// patchMonetizeBinding adds the default OpenClaw instance's ServiceAccount // as a subject on the monetize ClusterRoleBindings and x402 RoleBinding. // // ClusterRoleBindings patched: @@ -74,7 +43,7 @@ func Init(cfg *config.Config, u *ui.UI) error { // RoleBindings patched: // openclaw-x402-pricing-binding (x402 namespace, pricing ConfigMap) func patchMonetizeBinding(cfg *config.Config, u *ui.UI) error { - namespace := fmt.Sprintf("openclaw-%s", agentID) + namespace := fmt.Sprintf("openclaw-%s", DefaultInstanceID) subject := []map[string]interface{}{ { @@ -128,13 +97,13 @@ func patchMonetizeBinding(cfg *config.Config, u *ui.UI) error { return nil } -// injectHeartbeatFile writes HEARTBEAT.md to the obol-agent's workspace path +// injectHeartbeatFile writes HEARTBEAT.md to the default instance's workspace // so OpenClaw runs monetize.py reconciliation on every heartbeat cycle. // OpenClaw reads HEARTBEAT.md from the agent workspace directory // (resolveAgentWorkspaceDir → /data/.openclaw/workspace/HEARTBEAT.md), // NOT the root .openclaw directory. func injectHeartbeatFile(cfg *config.Config, u *ui.UI) error { - namespace := fmt.Sprintf("openclaw-%s", agentID) + namespace := fmt.Sprintf("openclaw-%s", DefaultInstanceID) heartbeatDir := filepath.Join(cfg.DataDir, namespace, "openclaw-data", ".openclaw", "workspace") if err := os.MkdirAll(heartbeatDir, 0755); err != nil { diff --git a/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml b/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml index 95658d46..72d7fc38 100644 --- a/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml +++ b/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml @@ -6,8 +6,8 @@ # 2. openclaw-monetize-workload — cluster-wide mutate for agent-managed resources # 3. openclaw-x402-pricing — namespace-scoped x402 pricing ConfigMap access # -# Subjects pre-populated with obol-agent ServiceAccount. -# Patched dynamically by `obol agent init` for additional instances. +# Subjects pre-populated with default OpenClaw instance ServiceAccount. +# Patched dynamically by `obol agent init` if needed. #------------------------------------------------------------------------------ # ClusterRole - Read-only permissions (low privilege, cluster-wide) @@ -98,7 +98,7 @@ roleRef: subjects: - kind: ServiceAccount name: openclaw - namespace: openclaw-obol-agent + namespace: openclaw-default --- #------------------------------------------------------------------------------ @@ -115,7 +115,7 @@ roleRef: subjects: - kind: ServiceAccount name: openclaw - namespace: openclaw-obol-agent + namespace: openclaw-default --- @@ -151,7 +151,7 @@ roleRef: subjects: - kind: ServiceAccount name: openclaw - namespace: openclaw-obol-agent + namespace: openclaw-default --- #------------------------------------------------------------------------------ @@ -186,4 +186,4 @@ roleRef: subjects: - kind: ServiceAccount name: openclaw - namespace: openclaw-obol-agent + namespace: openclaw-default diff --git a/internal/embed/infrastructure/helmfile.yaml b/internal/embed/infrastructure/helmfile.yaml index 7363150d..7eab9ee7 100644 --- a/internal/embed/infrastructure/helmfile.yaml +++ b/internal/embed/infrastructure/helmfile.yaml @@ -174,6 +174,8 @@ releases: name: erpc namespace: erpc spec: + hostnames: + - "obol.stack" parentRefs: - name: traefik-gateway namespace: traefik @@ -286,6 +288,10 @@ releases: - apiGroups: [""] resources: ["pods", "configmaps", "secrets"] verbs: ["get", "list"] + # ServiceOffer CRD — frontend sell modal creates offers + - apiGroups: ["obol.org"] + resources: ["serviceoffers", "serviceoffers/status"] + verbs: ["get", "list", "create", "update", "patch", "delete"] - apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: diff --git a/internal/inference/gateway.go b/internal/inference/gateway.go index 9250ae36..a9bc65eb 100644 --- a/internal/inference/gateway.go +++ b/internal/inference/gateway.go @@ -93,6 +93,12 @@ type GatewayConfig struct { // Required when TEEType is set. Bound into the TEE attestation user_data // so verifiers can confirm the model identity. ModelHash string + + // NoPaymentGate disables the built-in x402 payment middleware. Use this + // when the gateway runs behind the cluster's x402 verifier (via Traefik + // ForwardAuth) to avoid double-gating requests. Enclave/TEE encryption + // middleware remains active when enabled. + NoPaymentGate bool } // Gateway is an x402-enabled reverse proxy for LLM inference with optional @@ -212,7 +218,10 @@ func (g *Gateway) buildHandler(upstreamURL string) (http.Handler, error) { if em != nil { h = em.wrap(h) } - return paymentMiddleware(h) + if !g.config.NoPaymentGate { + h = paymentMiddleware(h) + } + return h } // Build HTTP mux. diff --git a/internal/inference/store.go b/internal/inference/store.go index 2a48784e..46d38673 100644 --- a/internal/inference/store.go +++ b/internal/inference/store.go @@ -85,6 +85,10 @@ type Deployment struct { // Required when TEEType is set. Bound into the TEE attestation user_data. ModelHash string `json:"model_hash,omitempty"` + // NoPaymentGate disables the built-in x402 payment middleware when the + // gateway is routed through the cluster's x402 verifier via Traefik. + NoPaymentGate bool `json:"no_payment_gate,omitempty"` + // CreatedAt is the RFC3339 timestamp of when this deployment was created. CreatedAt string `json:"created_at"` diff --git a/internal/openclaw/monetize_integration_test.go b/internal/openclaw/monetize_integration_test.go index 2d818dc7..5d26e81b 100644 --- a/internal/openclaw/monetize_integration_test.go +++ b/internal/openclaw/monetize_integration_test.go @@ -300,12 +300,11 @@ func TestIntegration_CRD_Delete(t *testing.T) { // ───────────────────────────────────────────────────────────────────────────── // agentNamespace returns the namespace of the OpenClaw instance that has -// monetize RBAC. Prefers "openclaw-obol-agent" (set up by `obol agent init`) -// over other instances, because only that SA gets the ClusterRoleBinding. +// monetize RBAC. This is always the "default" instance ("openclaw-default"). func agentNamespace(cfg *config.Config) string { out, err := obolRunErr(cfg, "openclaw", "list") if err != nil { - return "openclaw-obol-agent" + return "openclaw-default" } // Collect all namespaces from output. var namespaces []string @@ -318,16 +317,16 @@ func agentNamespace(cfg *config.Config) string { } } } - // Prefer obol-agent (has RBAC from `obol agent init`). + // Prefer default (has RBAC from `obol agent init`). for _, ns := range namespaces { - if ns == "openclaw-obol-agent" { + if ns == "openclaw-default" { return ns } } if len(namespaces) > 0 { return namespaces[0] } - return "openclaw-obol-agent" + return "openclaw-default" } // requireAgent skips the test if no OpenClaw instance is deployed. diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go index 3f514216..96db3382 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -66,7 +66,9 @@ type OnboardOptions struct { OllamaModels []string // Available Ollama models detected on host (nil = not queried) } -// SetupDefault deploys a default OpenClaw instance as part of stack setup. +// SetupDefault deploys the default OpenClaw instance as part of stack setup. +// This is the single canonical instance that handles both user-facing inference +// and agent-mode monetize/heartbeat reconciliation. // It is idempotent: if a "default" deployment already exists, it re-syncs. // When Ollama is not detected on the host and no existing ~/.openclaw config // is found, it skips provider setup gracefully so the user can configure @@ -81,6 +83,7 @@ func SetupDefault(cfg *config.Config, u *ui.UI) error { ID: "default", Sync: true, IsDefault: true, + AgentMode: true, }, u) } @@ -117,6 +120,7 @@ func SetupDefault(cfg *config.Config, u *ui.UI) error { ID: "default", Sync: true, IsDefault: true, + AgentMode: true, OllamaModels: ollamaModels, }, u) } diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 0c39971e..e5046c90 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -451,24 +451,30 @@ func syncDefaults(cfg *config.Config, u *ui.UI, kubeconfigPath string, dataDir s u.Dim(" You can manually set up OpenClaw later with: obol openclaw onboard") } - // Deploy the obol-agent singleton (monetize reconciliation, heartbeat). + // Apply agent capabilities (RBAC + heartbeat) to the default instance. // Non-fatal: the user can always run `obol agent init` later. u.Blank() - u.Info("Deploying obol-agent") + u.Info("Applying agent capabilities") if err := agent.Init(cfg, u); err != nil { - u.Warnf("Failed to deploy obol-agent: %v", err) - u.Dim(" You can manually deploy later with: obol agent init") + u.Warnf("Failed to apply agent capabilities: %v", err) + u.Dim(" You can manually apply later with: obol agent init") } - // Start the Cloudflare tunnel so the stack is publicly accessible. - // Non-fatal: the user can start it later with `obol tunnel restart`. + // Start the Cloudflare tunnel only if a persistent DNS tunnel is provisioned. + // Quick tunnels are dormant by default and activate on first `obol sell`. u.Blank() - u.Info("Starting Cloudflare tunnel") - if tunnelURL, err := tunnel.EnsureRunning(cfg, u); err != nil { - u.Warnf("Tunnel not started: %v", err) - u.Dim(" Start manually with: obol tunnel restart") + if st, _ := tunnel.LoadTunnelState(cfg); st != nil && st.Mode == "dns" && st.Hostname != "" { + u.Info("Starting persistent Cloudflare tunnel") + if tunnelURL, err := tunnel.EnsureRunning(cfg, u); err != nil { + u.Warnf("Tunnel not started: %v", err) + u.Dim(" Start manually with: obol tunnel restart") + } else { + u.Successf("Tunnel active: %s", tunnelURL) + } } else { - u.Successf("Tunnel active: %s", tunnelURL) + u.Dim("Tunnel dormant (activates on first 'obol sell http')") + u.Dim(" Start manually with: obol tunnel restart") + u.Dim(" For a persistent URL: obol tunnel login --hostname stack.example.com") } return nil diff --git a/internal/tunnel/agent.go b/internal/tunnel/agent.go index 3656ea7d..03b90d1f 100644 --- a/internal/tunnel/agent.go +++ b/internal/tunnel/agent.go @@ -10,7 +10,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/config" ) -const agentDeploymentID = "obol-agent" +const agentDeploymentID = "default" // SyncAgentBaseURL patches AGENT_BASE_URL in the obol-agent's values-obol.yaml // and runs helmfile sync to apply the change. It is a no-op if the obol-agent diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index d82eac9d..02fefe94 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -1,7 +1,9 @@ package tunnel import ( + "encoding/json" "fmt" + "net/url" "os" "os/exec" "path/filepath" @@ -39,12 +41,12 @@ func Status(cfg *config.Config, u *ui.UI) error { if err != nil { mode, url := tunnelModeAndURL(st) if mode == "quick" { - // No tunnel credentials configured — tunnel is dormant by design. - printStatusBox(u, "disabled", "not running", "(no tunnel configured)", time.Now()) + // Quick tunnel is dormant — activates on first `obol sell`. + printStatusBox(u, "quick", "dormant", "(activates on 'obol sell')", time.Now()) u.Blank() - u.Print("To expose your stack publicly, set up a tunnel:") - u.Print(" obol tunnel login --hostname stack.example.com") - u.Print(" obol tunnel provision --hostname stack.example.com --account-id ... --zone-id ... --api-token ...") + u.Print("The tunnel will start automatically when you sell a service.") + u.Print(" Start manually: obol tunnel restart") + u.Print(" Persistent URL: obol tunnel login --hostname stack.example.com") return nil } printStatusBox(u, mode, "not running", url, time.Now()) @@ -91,7 +93,7 @@ func Status(cfg *config.Config, u *ui.UI) error { return nil } -// InjectBaseURL sets AGENT_BASE_URL on the obol-agent deployment so that +// InjectBaseURL sets AGENT_BASE_URL on the default OpenClaw deployment so that // monetize.py uses the tunnel URL in registration JSON. func InjectBaseURL(cfg *config.Config, tunnelURL string) error { kubectlPath := filepath.Join(cfg.BinDir, "kubectl") @@ -100,7 +102,7 @@ func InjectBaseURL(cfg *config.Config, tunnelURL string) error { cmd := exec.Command(kubectlPath, "--kubeconfig", kubeconfigPath, "set", "env", "deployment/openclaw", - "-n", "openclaw-obol-agent", + "-n", "openclaw-default", fmt.Sprintf("AGENT_BASE_URL=%s", strings.TrimRight(tunnelURL, "/")), ) return cmd.Run() @@ -329,6 +331,241 @@ data: return nil } +// EnsureTunnelForSell ensures the tunnel is running and propagates the URL to +// all downstream consumers (obol-agent env, frontend ConfigMap, agent overlay). +// It also creates a storefront landing page at the tunnel hostname. +func EnsureTunnelForSell(cfg *config.Config, u *ui.UI) (string, error) { + tunnelURL, err := EnsureRunning(cfg, u) + if err != nil { + return "", err + } + // EnsureRunning already calls InjectBaseURL + SyncTunnelConfigMap. + // Also sync the agent overlay for helmfile consistency. + if err := SyncAgentBaseURL(cfg, tunnelURL); err != nil { + u.Warnf("could not sync AGENT_BASE_URL to obol-agent overlay: %v", err) + } + // Create the storefront landing page for the tunnel hostname. + if err := CreateStorefront(cfg, tunnelURL); err != nil { + u.Warnf("could not create storefront: %v", err) + } + return tunnelURL, nil +} + +// Stop scales the cloudflared deployment to 0 replicas. +func Stop(cfg *config.Config, u *ui.UI) error { + kubectlPath := filepath.Join(cfg.BinDir, "kubectl") + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { + return nil // stack not running, nothing to stop + } + cmd := exec.Command(kubectlPath, + "--kubeconfig", kubeconfigPath, + "scale", "deployment/cloudflared", + "-n", tunnelNamespace, + "--replicas=0", + ) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to scale cloudflared to 0: %w: %s", err, strings.TrimSpace(string(out))) + } + u.Success("Tunnel stopped") + return nil +} + +// storefrontNamespace is where the storefront landing page resources live. +const storefrontNamespace = "traefik" + +// CreateStorefront creates (or updates) a simple HTML landing page served at +// the tunnel hostname's root path. This uses the same busybox-httpd + ConfigMap +// pattern as the .well-known registration in monetize.py. +func CreateStorefront(cfg *config.Config, tunnelURL string) error { + parsed, err := url.Parse(tunnelURL) + if err != nil { + return fmt.Errorf("invalid tunnel URL: %w", err) + } + hostname := parsed.Hostname() + + kubectlPath := filepath.Join(cfg.BinDir, "kubectl") + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + + html := fmt.Sprintf(` + + + + + Obol Stack + + + +

Obol Stack

+

This node sells services via x402 micropayments.

+
+

Available Services

+

See the machine-readable catalog: /skill.md

+

Agent registration: /.well-known/agent-registration.json

+
+ +`, tunnelURL, tunnelURL) + + // Build the resources as a multi-document YAML manifest. + resources := []map[string]interface{}{ + // ConfigMap with HTML content + httpd mime config. + { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "tunnel-storefront", + "namespace": storefrontNamespace, + }, + "data": map[string]string{ + "index.html": html, + "httpd.conf": "", + "mime.types": "text/html\thtml htm\n", + }, + }, + // Deployment: busybox httpd serving the ConfigMap. + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "tunnel-storefront", + "namespace": storefrontNamespace, + }, + "spec": map[string]interface{}{ + "replicas": 1, + "selector": map[string]interface{}{ + "matchLabels": map[string]string{"app": "tunnel-storefront"}, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]string{"app": "tunnel-storefront"}, + }, + "spec": map[string]interface{}{ + "containers": []map[string]interface{}{ + { + "name": "httpd", + "image": "busybox:1.37", + "command": []string{"httpd", "-f", "-p", "8080", "-h", "/www"}, + "ports": []map[string]interface{}{ + {"containerPort": 8080}, + }, + "volumeMounts": []map[string]interface{}{ + {"name": "html", "mountPath": "/www"}, + }, + "resources": map[string]interface{}{ + "requests": map[string]string{"cpu": "5m", "memory": "8Mi"}, + "limits": map[string]string{"cpu": "20m", "memory": "16Mi"}, + }, + }, + }, + "volumes": []map[string]interface{}{ + { + "name": "html", + "configMap": map[string]interface{}{ + "name": "tunnel-storefront", + }, + }, + }, + }, + }, + }, + }, + // Service + { + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": "tunnel-storefront", + "namespace": storefrontNamespace, + }, + "spec": map[string]interface{}{ + "selector": map[string]string{"app": "tunnel-storefront"}, + "ports": []map[string]interface{}{ + {"port": 8080, "targetPort": 8080}, + }, + }, + }, + // HTTPRoute: tunnel hostname → storefront (more specific than frontend catch-all). + { + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "HTTPRoute", + "metadata": map[string]interface{}{ + "name": "tunnel-storefront", + "namespace": storefrontNamespace, + }, + "spec": map[string]interface{}{ + "hostnames": []string{hostname}, + "parentRefs": []map[string]interface{}{ + { + "name": "traefik-gateway", + "namespace": "traefik", + "sectionName": "web", + }, + }, + "rules": []map[string]interface{}{ + { + "matches": []map[string]interface{}{ + {"path": map[string]string{"type": "PathPrefix", "value": "/"}}, + }, + "backendRefs": []map[string]interface{}{ + { + "name": "tunnel-storefront", + "port": 8080, + }, + }, + }, + }, + }, + }, + } + + // Apply each resource via kubectl apply. + for _, res := range resources { + data, err := json.Marshal(res) + if err != nil { + return fmt.Errorf("failed to marshal resource: %w", err) + } + cmd := exec.Command(kubectlPath, + "--kubeconfig", kubeconfigPath, + "apply", "-f", "-", + ) + cmd.Stdin = strings.NewReader(string(data)) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to apply storefront resource: %w: %s", err, strings.TrimSpace(string(out))) + } + } + return nil +} + +// DeleteStorefront removes the storefront landing page resources. +func DeleteStorefront(cfg *config.Config) error { + kubectlPath := filepath.Join(cfg.BinDir, "kubectl") + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { + return nil + } + + for _, resource := range []string{ + "httproute/tunnel-storefront", + "service/tunnel-storefront", + "deployment/tunnel-storefront", + "configmap/tunnel-storefront", + } { + cmd := exec.Command(kubectlPath, + "--kubeconfig", kubeconfigPath, + "delete", resource, + "-n", storefrontNamespace, + "--ignore-not-found", + ) + _ = cmd.Run() // best-effort cleanup + } + return nil +} + func parseQuickTunnelURL(logs string) (string, bool) { // Quick tunnel logs print a random *.trycloudflare.com URL. re := regexp.MustCompile(`https://[a-z0-9-]+\.trycloudflare\.com`) diff --git a/internal/x402/bdd_integration_test.go b/internal/x402/bdd_integration_test.go index 8a41ae37..b17190f5 100644 --- a/internal/x402/bdd_integration_test.go +++ b/internal/x402/bdd_integration_test.go @@ -162,7 +162,7 @@ func TestMain(m *testing.M) { // Wait for the obol-agent pod to be Running. log.Println(" Waiting for obol-agent pod...") - if err := waitForAnyPod(kubectlBin, kubeconfigPath, "openclaw-obol-agent", + if err := waitForAnyPod(kubectlBin, kubeconfigPath, "openclaw-default", []string{"app=openclaw", "app.kubernetes.io/name=openclaw"}, 300*time.Second); err != nil { teardown(obolBin) log.Fatalf("obol-agent not ready: %v", err) @@ -299,7 +299,7 @@ func ensureExistingClusterBootstrap(obolBin, kubectlBin, kubeconfig string) erro if err := waitForPod(kubectlBin, kubeconfig, "x402", "app=x402-verifier", 120*time.Second); err != nil { return fmt.Errorf("x402-verifier not ready: %w", err) } - if err := waitForAnyPod(kubectlBin, kubeconfig, "openclaw-obol-agent", + if err := waitForAnyPod(kubectlBin, kubeconfig, "openclaw-default", []string{"app=openclaw", "app.kubernetes.io/name=openclaw"}, 180*time.Second); err != nil { return fmt.Errorf("obol-agent not ready: %w", err) } @@ -393,7 +393,7 @@ func waitForServiceOfferReady(kubectlBin, kubeconfig, name, namespace string, ti // This simulates the heartbeat cron firing. func triggerReconciliation(kubectlBin, kubeconfig string) { out, err := kubectl.Output(kubectlBin, kubeconfig, - "exec", "-i", "-n", "openclaw-obol-agent", "deploy/openclaw", "-c", "openclaw", + "exec", "-i", "-n", "openclaw-default", "deploy/openclaw", "-c", "openclaw", "--", "python3", "/data/.openclaw/skills/sell/scripts/monetize.py", "process", "--all") if err != nil { log.Printf(" manual reconciliation error: %v\n%s", err, out) diff --git a/internal/x402/setup.go b/internal/x402/setup.go index 6bc77696..b9d644e0 100644 --- a/internal/x402/setup.go +++ b/internal/x402/setup.go @@ -169,7 +169,7 @@ roleRef: subjects: - kind: ServiceAccount name: openclaw - namespace: openclaw-obol-agent + namespace: openclaw-default `) // EnsureVerifier deploys the x402 verifier subsystem if it doesn't exist. From 3a09375c1546f7e027229db37e76ca42f117cd20 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Tue, 17 Mar 2026 11:12:24 +0800 Subject: [PATCH 02/14] security: restrict frontend HTTPRoute to local-only via hostname binding The frontend catch-all `/` HTTPRoute had no hostname restriction, meaning the entire UI (dashboard, sell modal, settings) was publicly accessible through the Cloudflare tunnel. Add `hostnames: ["obol.stack"]` to match the eRPC route pattern already in this branch. Also add CLAUDE.md guardrails documenting the local-only vs public route split and explicit NEVER rules to prevent future regressions. --- CLAUDE.md | 18 +++++++++++++++++- internal/embed/infrastructure/helmfile.yaml | 2 ++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index a3b81238..1de55441 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Integration tests use `//go:build integration` and skip gracefully when prerequi **Design**: Deployment-centric (unique namespaces via petnames), local-first (k3d), XDG-compliant, two-stage templating (CLI flags → Go templates → Helmfile → K8s). -**Routing**: Traefik + Kubernetes Gateway API. GatewayClass `traefik`, Gateway `traefik-gateway` in `traefik` ns. Routes: `/` → frontend, `/rpc` → eRPC, `/services//*` → x402 ForwardAuth → upstream, `/.well-known/agent-registration.json` → ERC-8004 httpd, `/ethereum-/execution|beacon`. +**Routing**: Traefik + Kubernetes Gateway API. GatewayClass `traefik`, Gateway `traefik-gateway` in `traefik` ns. Local-only routes (restricted to `hostnames: ["obol.stack"]`): `/` → frontend, `/rpc` → eRPC. Public routes (accessible via tunnel, no hostname restriction): `/services//*` → x402 ForwardAuth → upstream, `/.well-known/agent-registration.json` → ERC-8004 httpd, `/skill.md` → service catalog. Tunnel hostname gets a storefront landing page at `/`. NEVER remove hostname restrictions from frontend or eRPC HTTPRoutes — exposing the frontend/RPC to the public internet is a critical security flaw. **Config**: `Config{ConfigDir, DataDir, BinDir}`. Precedence: `OBOL_CONFIG_DIR` > `XDG_CONFIG_HOME/obol` > `~/.config/obol`. `OBOL_DEVELOPMENT=true` → `.workspace/` dirs. All K8s tools auto-set `KUBECONFIG=$OBOL_CONFIG_DIR/kubeconfig.yaml`. @@ -156,6 +156,22 @@ Skills = SKILL.md + optional scripts/references, embedded in `obol` binary (`int 4. **ExternalName services** — don't work with Traefik Gateway API, use ClusterIP + Endpoints 5. **eRPC `eth_call` cache** — default TTL is 10s for unfinalized reads, so `buy.py balance` can lag behind an already-settled paid request for a few seconds +### Security: Tunnel Exposure + +The Cloudflare tunnel exposes the cluster to the public internet. Only x402-gated endpoints and discovery metadata should be reachable via the tunnel hostname. Internal services (frontend, eRPC, LiteLLM, monitoring) MUST have `hostnames: ["obol.stack"]` on their HTTPRoutes to restrict them to local access. + +**NEVER**: +- Remove `hostnames` restrictions from frontend or eRPC HTTPRoutes +- Create HTTPRoutes without `hostnames` for internal services +- Expose the frontend UI, Prometheus/monitoring, or LiteLLM admin to the tunnel +- Run `obol stack down` or `obol stack purge` unless explicitly asked + +**Public routes** (no hostname restriction, intentional): +- `/services/*` — x402 payment-gated, safe by design +- `/.well-known/agent-registration.json` — ERC-8004 discovery +- `/skill.md` — machine-readable service catalog +- `/` on tunnel hostname — static storefront landing page (busybox httpd) + ## Key Packages | Package | Key Files | Role | diff --git a/internal/embed/infrastructure/helmfile.yaml b/internal/embed/infrastructure/helmfile.yaml index 7eab9ee7..05ff5f27 100644 --- a/internal/embed/infrastructure/helmfile.yaml +++ b/internal/embed/infrastructure/helmfile.yaml @@ -253,6 +253,8 @@ releases: name: obol-frontend namespace: obol-frontend spec: + hostnames: + - "obol.stack" parentRefs: - name: traefik-gateway namespace: traefik From fa0779198caaf9d47e413eb282ef34b8dc2adc5c Mon Sep 17 00:00:00 2001 From: bussyjd Date: Tue, 17 Mar 2026 14:48:02 +0800 Subject: [PATCH 03/14] fix: address security and routing issues from PR #267 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes for the sell-inference cluster routing introduced in #267: 1. Security: bind gateway to 127.0.0.1 when NoPaymentGate=true so only cluster traffic (via K8s Service+Endpoints bridge) can reach the unpaid listener — no host/LAN exposure. 2. Critical: use parsed --listen port in Service, Endpoints, and ServiceOffer spec instead of hardcoded 8402. Non-default ports now work correctly. 3. k3s support: resolveHostIP() now checks DetectExistingBackend() for k3s and returns 127.0.0.1, matching the existing ollamaHostIPForBackend() strategy in internal/stack. 4. Migration: keep "obol-agent" as default instance ID to preserve existing openclaw-obol-agent namespaces on upgrade. Avoids orphaned deployments when upgrading from pre-#267 installs. Also bumps frontend to v0.1.13-rc.1. --- cmd/obol/sell.go | 34 ++++++++++++++----- internal/agent/agent.go | 2 +- .../values/obol-frontend.yaml.gotmpl | 2 +- internal/openclaw/openclaw.go | 8 ++--- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 54452d66..36978a82 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -9,6 +9,7 @@ import ( "os" "os/signal" "runtime" + "strconv" "strings" "syscall" @@ -18,6 +19,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/inference" "github.com/ObolNetwork/obol-stack/internal/kubectl" "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" x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" @@ -231,6 +233,11 @@ Examples: port = listenAddr[idx+1:] } + // Bind to loopback only — the cluster reaches us via the + // K8s Service+Endpoints bridge; there is no reason to expose + // the unpaid gateway on all interfaces. + d.ListenAddr = "127.0.0.1:" + port + // Create a K8s Service + Endpoints pointing to the host. svcNs := "llm" // co-locate with LiteLLM for simplicity if err := createHostService(cfg, name, svcNs, port); err != nil { @@ -1119,11 +1126,13 @@ func formatInferencePriceSummary(d *inference.Deployment) string { // Kubernetes Endpoints require an IP address, not a hostname. We resolve the // host IP using the same strategy as ollamaHostIPForBackend in internal/stack. func createHostService(cfg *config.Config, name, ns, port string) error { - hostIP, err := resolveHostIP() + hostIP, err := resolveHostIP(cfg) if err != nil { return fmt.Errorf("cannot resolve host IP for cluster routing: %w", err) } + portNum, _ := strconv.Atoi(port) + svc := map[string]interface{}{ "apiVersion": "v1", "kind": "Service", @@ -1133,7 +1142,7 @@ func createHostService(cfg *config.Config, name, ns, port string) error { }, "spec": map[string]interface{}{ "ports": []map[string]interface{}{ - {"port": 8402, "targetPort": 8402, "protocol": "TCP"}, + {"port": portNum, "targetPort": portNum, "protocol": "TCP"}, }, }, } @@ -1150,7 +1159,7 @@ func createHostService(cfg *config.Config, name, ns, port string) error { {"ip": hostIP}, }, "ports": []map[string]interface{}{ - {"port": 8402, "protocol": "TCP"}, + {"port": portNum, "protocol": "TCP"}, }, }, }, @@ -1165,10 +1174,16 @@ func createHostService(cfg *config.Config, name, ns, port string) error { return nil } -// resolveHostIP returns the Docker host IP reachable from k3d containers. -// Same resolution strategy as stack.ollamaHostIPForBackend. -func resolveHostIP() (string, error) { - // Try DNS resolution of host.docker.internal (macOS) or host.k3d.internal (Linux). +// resolveHostIP returns the host IP reachable from cluster containers. +// For k3s (bare-metal) the host is localhost; for k3d the host is +// reachable via Docker networking. +func resolveHostIP(cfg *config.Config) (string, error) { + // Check if this is a k3s (bare-metal) backend — host is localhost. + if backend := stack.DetectExistingBackend(cfg); backend == stack.BackendK3s { + return "127.0.0.1", nil + } + + // k3d / Docker: try DNS resolution of host.docker.internal or host.k3d.internal. for _, host := range []string{"host.docker.internal", "host.k3d.internal"} { if addrs, err := net.LookupHost(host); err == nil && len(addrs) > 0 { return addrs[0], nil @@ -1188,18 +1203,19 @@ func resolveHostIP() (string, error) { } } } - return "", fmt.Errorf("cannot determine Docker host IP; ensure Docker is running") + return "", fmt.Errorf("cannot determine host IP; ensure Docker is running or using k3s backend") } // buildInferenceServiceOfferSpec builds a ServiceOffer spec for a host-side // inference gateway routed through the cluster's x402 flow. func buildInferenceServiceOfferSpec(d *inference.Deployment, pt schemas.PriceTable, ns, port string) map[string]interface{} { + portNum, _ := strconv.Atoi(port) spec := map[string]interface{}{ "type": "inference", "upstream": map[string]interface{}{ "service": d.Name, "namespace": ns, - "port": 8402, + "port": portNum, "healthPath": "/health", }, "payment": map[string]interface{}{ diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 63f8ae94..63b6f602 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -13,7 +13,7 @@ import ( // DefaultInstanceID is the canonical OpenClaw instance that runs both // user-facing inference and agent-mode monetize/heartbeat reconciliation. -const DefaultInstanceID = "default" +const DefaultInstanceID = "obol-agent" // Init patches the default OpenClaw instance with agent capabilities: // monetize RBAC bindings and HEARTBEAT.md for periodic reconciliation. diff --git a/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl b/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl index 5d9d7235..043efb8b 100644 --- a/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl +++ b/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl @@ -35,7 +35,7 @@ image: repository: obolnetwork/obol-stack-front-end pullPolicy: IfNotPresent - tag: "v0.1.12" + tag: "v0.1.13-rc.1" service: type: ClusterIP diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go index 96db3382..17a21260 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -76,11 +76,11 @@ type OnboardOptions struct { func SetupDefault(cfg *config.Config, u *ui.UI) error { // Check whether the default deployment already exists (re-sync path). // If it does, proceed unconditionally — the overlay was already written. - deploymentDir := DeploymentPath(cfg, "default") + deploymentDir := DeploymentPath(cfg, "obol-agent") if _, err := os.Stat(deploymentDir); err == nil { // Existing deployment — always re-sync regardless of Ollama status. return Onboard(cfg, OnboardOptions{ - ID: "default", + ID: "obol-agent", Sync: true, IsDefault: true, AgentMode: true, @@ -117,7 +117,7 @@ func SetupDefault(cfg *config.Config, u *ui.UI) error { } return Onboard(cfg, OnboardOptions{ - ID: "default", + ID: "obol-agent", Sync: true, IsDefault: true, AgentMode: true, @@ -129,7 +129,7 @@ func SetupDefault(cfg *config.Config, u *ui.UI) error { func Onboard(cfg *config.Config, opts OnboardOptions, u *ui.UI) error { id := opts.ID if opts.IsDefault { - id = "default" + id = "obol-agent" } if id == "" { id = petname.Generate(2, "-") From 0b91ec269dcfd98b88d30c70a0a6ecaba6f70816 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Tue, 17 Mar 2026 14:55:18 +0800 Subject: [PATCH 04/14] chore: bump OpenClaw to v2026.3.13-1 --- internal/openclaw/openclaw.go | 2 +- obolup.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go index 17a21260..a2896afd 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -48,7 +48,7 @@ const ( // openclawImageTag overrides the chart's default image tag. // Must match the version in OPENCLAW_VERSION (without "v" prefix). - openclawImageTag = "2026.3.11" + openclawImageTag = "2026.3.13-1" // remoteSignerChartVersion pins the remote-signer Helm chart version. // renovate: datasource=helm depName=remote-signer registryUrl=https://obolnetwork.github.io/helm-charts/ diff --git a/obolup.sh b/obolup.sh index c11ca4d0..f2ed6ae3 100755 --- a/obolup.sh +++ b/obolup.sh @@ -60,7 +60,7 @@ readonly K3D_VERSION="5.8.3" readonly HELMFILE_VERSION="1.2.3" readonly K9S_VERSION="0.50.18" readonly HELM_DIFF_VERSION="3.14.1" -readonly OPENCLAW_VERSION="2026.3.11" +readonly OPENCLAW_VERSION="2026.3.13-1" # Repository URL for building from source readonly OBOL_REPO_URL="git@github.com:ObolNetwork/obol-stack.git" From ee92f932cd2ee6f78e4d54e1439fb65e3646392e Mon Sep 17 00:00:00 2001 From: bussyjd Date: Tue, 17 Mar 2026 16:19:49 +0800 Subject: [PATCH 05/14] feat: auto-configure cloud providers during stack up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ~/.openclaw/openclaw.json specifies a cloud model as the agent's primary (e.g. anthropic/claude-sonnet-4-6), autoConfigureLLM() now detects the provider and API key from the environment (or .env in dev mode) and configures LiteLLM before OpenClaw setup runs. This makes agent chat work out of the box without a separate `obol model setup`. Changes: - internal/stack: add autoConfigureCloudProviders() with env + .env key resolution (dev-mode only for .env) - internal/model: export ProviderFromModelName(), ProviderEnvVar(); add HasProviderConfigured(), LoadDotEnv() - cmd/obol/model: update defaults — claude-sonnet-4-6, gpt-4.1 - internal/model: update WellKnownModels with current flagship models (claude-opus-4-6, gpt-5.4, gpt-4.1, o4-mini) - obolup.sh: add check_agent_model_api_key() to warn users before cluster start if a required API key is missing --- cmd/obol/model.go | 4 +- internal/model/model.go | 88 +++++++++++++++++++++++++++----- internal/model/model_test.go | 77 ++++++++++++++++++++++++++-- internal/stack/stack.go | 97 ++++++++++++++++++++++++++++-------- obolup.sh | 43 ++++++++++++++++ 5 files changed, 271 insertions(+), 38 deletions(-) diff --git a/cmd/obol/model.go b/cmd/obol/model.go index 1a9a5909..c52199a4 100644 --- a/cmd/obol/model.go +++ b/cmd/obol/model.go @@ -145,9 +145,9 @@ func setupCloudProvider(cfg *config.Config, u *ui.UI, provider, apiKey string, m // Sensible defaults switch provider { case "anthropic": - models = []string{"claude-sonnet-4-5-20250929"} + models = []string{"claude-sonnet-4-6"} case "openai": - models = []string{"gpt-4o"} + models = []string{"gpt-4.1"} } } diff --git a/internal/model/model.go b/internal/model/model.go index 362d8db9..1ad61558 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -93,6 +93,69 @@ func HasConfiguredModels(cfg *config.Config) bool { return false } +// HasProviderConfigured returns true if LiteLLM already has at least one +// model entry for the given provider (e.g., "anthropic", "openai"). +func HasProviderConfigured(cfg *config.Config, provider string) bool { + kubectlBinary := filepath.Join(cfg.BinDir, "kubectl") + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + + raw, err := kubectl.Output(kubectlBinary, kubeconfigPath, + "get", "configmap", configMapName, "-n", namespace, "-o", "jsonpath={.data.config\\.yaml}") + if err != nil { + return false + } + + var litellmConfig LiteLLMConfig + if err := yaml.Unmarshal([]byte(raw), &litellmConfig); err != nil { + return false + } + + for _, entry := range litellmConfig.ModelList { + // Check wildcard entries like "anthropic/*" + if entry.ModelName == provider+"/*" { + return true + } + // Check if the model's litellm_params.model starts with "provider/" + if strings.HasPrefix(entry.LiteLLMParams.Model, provider+"/") { + return true + } + // Check via model name inference + if ProviderFromModelName(entry.ModelName) == provider { + return true + } + } + return false +} + +// LoadDotEnv reads KEY=value pairs from a .env file. +// Returns an empty map if the file doesn't exist or is unreadable. +// Skips comments (#) and blank lines. Does not call os.Setenv. +func LoadDotEnv(path string) map[string]string { + result := make(map[string]string) + data, err := os.ReadFile(path) + if err != nil { + return result + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.IndexByte(line, '=') + if idx < 1 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + // Strip surrounding quotes + if len(val) >= 2 && ((val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'')) { + val = val[1 : len(val)-1] + } + result[key] = val + } + return result +} + // ConfigureLiteLLM adds a provider to the LiteLLM gateway. // For cloud providers, it patches the Secret with the API key and adds // the model to config.yaml. For Ollama, it discovers local models and adds them. @@ -105,7 +168,7 @@ func ConfigureLiteLLM(cfg *config.Config, u *ui.UI, provider, apiKey string, mod } // 1. Patch Secret with API key (if cloud provider) - envVar := providerEnvVar(provider) + envVar := ProviderEnvVar(provider) if envVar != "" && apiKey != "" { u.Infof("Setting %s API key", provider) patchJSON := fmt.Sprintf(`{"stringData":{"%s":"%s"}}`, envVar, apiKey) @@ -546,7 +609,7 @@ func expandWildcard(provider string, liveModels []string) []string { if len(liveModels) > 0 { var matched []string for _, m := range liveModels { - p := detectProviderFromModelName(m) + p := ProviderFromModelName(m) if p == provider { matched = append(matched, m) } @@ -562,12 +625,12 @@ func expandWildcard(provider string, liveModels []string) []string { return nil } -// detectProviderFromModelName infers the provider from a model name string. -func detectProviderFromModelName(name string) string { +// ProviderFromModelName infers the provider from a model name string. +func ProviderFromModelName(name string) string { if strings.Contains(name, "claude") { return "anthropic" } - if strings.HasPrefix(name, "gpt") || strings.HasPrefix(name, "o1") || strings.HasPrefix(name, "o3") { + if strings.HasPrefix(name, "gpt") || strings.HasPrefix(name, "o1") || strings.HasPrefix(name, "o3") || strings.HasPrefix(name, "o4") { return "openai" } return "" @@ -575,8 +638,8 @@ func detectProviderFromModelName(name string) string { // --- Internal helpers --- -// providerEnvVar returns the env var name for a provider's API key. -func providerEnvVar(provider string) string { +// ProviderEnvVar returns the env var name for a provider's API key. +func ProviderEnvVar(provider string) string { for _, p := range knownProviders { if p.ID == provider { return p.EnvVar @@ -590,16 +653,17 @@ func providerEnvVar(provider string) string { // and the LiteLLM pod is not reachable for a live /v1/models query. var WellKnownModels = map[string][]string{ "anthropic": { + "claude-opus-4-6", "claude-sonnet-4-6", - "claude-opus-4", + "claude-haiku-4-5-20251001", "claude-sonnet-4-5-20250929", - "claude-haiku-3-5-20241022", }, "openai": { - "gpt-4o", - "gpt-4o-mini", + "gpt-5.4", + "gpt-4.1", + "gpt-4.1-mini", + "o4-mini", "o3", - "o3-mini", }, } diff --git a/internal/model/model_test.go b/internal/model/model_test.go index a69eaa5b..c0d8d604 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" ) @@ -201,20 +203,87 @@ general_settings: } func TestProviderEnvVar(t *testing.T) { - if got := providerEnvVar("anthropic"); got != "ANTHROPIC_API_KEY" { + if got := ProviderEnvVar("anthropic"); got != "ANTHROPIC_API_KEY" { t.Errorf("got %q, want ANTHROPIC_API_KEY", got) } - if got := providerEnvVar("openai"); got != "OPENAI_API_KEY" { + if got := ProviderEnvVar("openai"); got != "OPENAI_API_KEY" { t.Errorf("got %q, want OPENAI_API_KEY", got) } - if got := providerEnvVar("ollama"); got != "" { + if got := ProviderEnvVar("ollama"); got != "" { t.Errorf("got %q, want empty string for ollama", got) } - if got := providerEnvVar("custom_thing"); got != "CUSTOM_THING_API_KEY" { + if got := ProviderEnvVar("custom_thing"); got != "CUSTOM_THING_API_KEY" { t.Errorf("got %q, want CUSTOM_THING_API_KEY", got) } } +func TestProviderFromModelName(t *testing.T) { + tests := []struct { + name string + model string + expected string + }{ + {"anthropic claude", "claude-sonnet-4-6", "anthropic"}, + {"anthropic full", "claude-opus-4", "anthropic"}, + {"openai gpt", "gpt-4o", "openai"}, + {"openai o3", "o3-mini", "openai"}, + {"ollama model", "qwen3.5:9b", ""}, + {"unknown", "llama-3.2", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ProviderFromModelName(tt.model); got != tt.expected { + t.Errorf("ProviderFromModelName(%q) = %q, want %q", tt.model, got, tt.expected) + } + }) + } +} + +func TestLoadDotEnv(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + os.WriteFile(path, []byte("FOO=bar\nBAZ=qux\n"), 0o644) + m := LoadDotEnv(path) + if m["FOO"] != "bar" { + t.Errorf("FOO = %q, want bar", m["FOO"]) + } + if m["BAZ"] != "qux" { + t.Errorf("BAZ = %q, want qux", m["BAZ"]) + } + }) + + t.Run("missing file", func(t *testing.T) { + m := LoadDotEnv("/nonexistent/.env") + if len(m) != 0 { + t.Errorf("expected empty map, got %v", m) + } + }) + + t.Run("comments and blanks", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + os.WriteFile(path, []byte("# comment\n\nKEY=val\n"), 0o644) + m := LoadDotEnv(path) + if len(m) != 1 || m["KEY"] != "val" { + t.Errorf("expected {KEY:val}, got %v", m) + } + }) + + t.Run("quoted values", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + os.WriteFile(path, []byte(`KEY="hello world"`+"\n"+`KEY2='single'`+"\n"), 0o644) + m := LoadDotEnv(path) + if m["KEY"] != "hello world" { + t.Errorf("KEY = %q, want 'hello world'", m["KEY"]) + } + if m["KEY2"] != "single" { + t.Errorf("KEY2 = %q, want 'single'", m["KEY2"]) + } + }) +} + func TestValidateCustomEndpoint(t *testing.T) { t.Run("full validation success", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/stack/stack.go b/internal/stack/stack.go index e5046c90..a8348864 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -480,37 +480,94 @@ func syncDefaults(cfg *config.Config, u *ui.UI, kubeconfigPath string, dataDir s return nil } -// autoConfigureLLM detects the host Ollama and auto-configures LiteLLM with -// available models so that inference works out of the box. Skipped silently -// if Ollama is unreachable, has no models, or LiteLLM already has non-paid -// models configured. +// autoConfigureLLM detects host Ollama and imported cloud providers, then +// auto-configures LiteLLM so inference works out of the box. func autoConfigureLLM(cfg *config.Config, u *ui.UI) { + // --- Ollama auto-configuration --- ollamaModels, err := model.ListOllamaModels() - if err != nil || len(ollamaModels) == 0 { - // Ollama not running or no models — skip silently. + if err == nil && len(ollamaModels) > 0 && !model.HasConfiguredModels(cfg) { + u.Blank() + u.Infof("Ollama detected with %d model(s) — auto-configuring LiteLLM", len(ollamaModels)) + + var names []string + for _, m := range ollamaModels { + name := m.Name + if strings.HasSuffix(name, ":latest") { + name = strings.TrimSuffix(name, ":latest") + } + names = append(names, name) + } + + if err := model.ConfigureLiteLLM(cfg, u, "ollama", "", names); err != nil { + u.Warnf("Auto-configure LiteLLM failed: %v", err) + u.Dim(" Run 'obol model setup' to configure manually.") + } + } + + // --- Cloud provider auto-configuration from ~/.openclaw --- + autoConfigureCloudProviders(cfg, u) +} + +// autoConfigureCloudProviders reads the imported ~/.openclaw config and, if a +// cloud model is the agent's primary model, auto-configures LiteLLM with the +// matching provider when an API key is available in the environment (or .env +// in dev mode). +func autoConfigureCloudProviders(cfg *config.Config, u *ui.UI) { + imported, err := openclaw.DetectExistingConfig() + if err != nil || imported == nil { return } - // Check if LiteLLM already has real models (not just the paid/* catch-all). - if model.HasConfiguredModels(cfg) { + agentModel := imported.AgentModel + if agentModel == "" { return } - u.Blank() - u.Infof("Ollama detected with %d model(s) — auto-configuring LiteLLM", len(ollamaModels)) + // Extract provider and model name from "anthropic/claude-sonnet-4-6". + provider, modelName := "", agentModel + if i := strings.Index(agentModel, "/"); i >= 0 { + provider = agentModel[:i] + modelName = agentModel[i+1:] + } + if provider == "" { + provider = model.ProviderFromModelName(agentModel) + } - var names []string - for _, m := range ollamaModels { - name := m.Name - if strings.HasSuffix(name, ":latest") { - name = strings.TrimSuffix(name, ":latest") - } - names = append(names, name) + if provider == "" || provider == "ollama" { + return } - if err := model.ConfigureLiteLLM(cfg, u, "ollama", "", names); err != nil { - u.Warnf("Auto-configure LiteLLM failed: %v", err) - u.Dim(" Run 'obol model setup' to configure manually.") + // Already configured — skip. + if model.HasProviderConfigured(cfg, provider) { + return + } + + envVar := model.ProviderEnvVar(provider) + if envVar == "" { + return + } + + // Resolve API key: environment first, .env in dev mode only. + apiKey := os.Getenv(envVar) + if apiKey == "" && os.Getenv("OBOL_DEVELOPMENT") == "true" { + dotEnv := model.LoadDotEnv(filepath.Join(".", ".env")) + apiKey = dotEnv[envVar] + } + + if apiKey == "" { + u.Blank() + u.Warnf("Agent model %s detected but %s is not set", agentModel, envVar) + u.Dim(fmt.Sprintf(" Set it in your environment: export %s=...", envVar)) + u.Dim(fmt.Sprintf(" Or configure after startup: obol model setup --provider %s", provider)) + return + } + + u.Blank() + u.Infof("Cloud model %s detected — auto-configuring LiteLLM with %s provider", agentModel, provider) + + if err := model.ConfigureLiteLLM(cfg, u, provider, apiKey, []string{modelName}); err != nil { + u.Warnf("Auto-configure LiteLLM for %s failed: %v", provider, err) + u.Dim(fmt.Sprintf(" Run 'obol model setup --provider %s' to configure manually.", provider)) } } diff --git a/obolup.sh b/obolup.sh index f2ed6ae3..b8ec7078 100755 --- a/obolup.sh +++ b/obolup.sh @@ -60,6 +60,8 @@ readonly K3D_VERSION="5.8.3" readonly HELMFILE_VERSION="1.2.3" readonly K9S_VERSION="0.50.18" readonly HELM_DIFF_VERSION="3.14.1" +# Must match internal/openclaw/OPENCLAW_VERSION (without "v" prefix). +# Tested by TestOpenClawVersionConsistency. readonly OPENCLAW_VERSION="2026.3.13-1" # Repository URL for building from source @@ -1494,6 +1496,44 @@ configure_path() { } # Print post-install instructions +# Check if ~/.openclaw/openclaw.json specifies a cloud model that needs an API key. +# Prints guidance before the "start cluster" prompt so the user can set the key first. +check_agent_model_api_key() { + local config_file="$HOME/.openclaw/openclaw.json" + [[ -f "$config_file" ]] || return 0 + + # Extract agents.defaults.model.primary (e.g., "anthropic/claude-sonnet-4-6") + local primary_model="" + if command_exists python3; then + primary_model=$(python3 -c " +import json, sys +try: + d = json.load(open('$config_file')) + print(d.get('agents',{}).get('defaults',{}).get('model',{}).get('primary','')) +except: pass +" 2>/dev/null) + fi + + [[ -n "$primary_model" ]] || return 0 + + # Determine provider and required env var + local provider="" env_var="" + case "$primary_model" in + *claude*) provider="anthropic"; env_var="ANTHROPIC_API_KEY" ;; + gpt*|o1*|o3*) provider="openai"; env_var="OPENAI_API_KEY" ;; + *) return 0 ;; + esac + + echo "" + if [[ -n "${!env_var:-}" ]]; then + log_success "$env_var detected for $primary_model" + else + log_warn "Your agent uses $primary_model but $env_var is not set." + log_dim " Set it before starting: export $env_var=..." + log_dim " Or configure after startup: obol model setup --provider $provider" + fi +} + print_instructions() { local install_mode="$1" @@ -1505,6 +1545,9 @@ print_instructions() { fi echo "" + # Check if the agent's primary model requires a cloud API key. + check_agent_model_api_key + # Check if we can prompt the user for bootstrap (works with curl | bash via /dev/tty) if [[ -c /dev/tty ]] && [[ -f "$OBOL_BIN_DIR/obol" ]]; then echo "" From a5d39940de01da89998f68c8a4a22e79d1d75295 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Tue, 17 Mar 2026 16:37:18 +0800 Subject: [PATCH 06/14] perf: batch LiteLLM provider config into single restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split ConfigureLiteLLM into PatchLiteLLMProvider (config-only) and RestartLiteLLM (restart+wait). autoConfigureLLM now patches Ollama and cloud providers first, then does one restart — halving startup time when both are configured. --- internal/model/model.go | 22 +++++++++++++-- internal/stack/stack.go | 59 ++++++++++++++++++++++++++--------------- 2 files changed, 58 insertions(+), 23 deletions(-) diff --git a/internal/model/model.go b/internal/model/model.go index 1ad61558..a4247527 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -159,7 +159,19 @@ func LoadDotEnv(path string) map[string]string { // ConfigureLiteLLM adds a provider to the LiteLLM gateway. // For cloud providers, it patches the Secret with the API key and adds // the model to config.yaml. For Ollama, it discovers local models and adds them. +// Restarts the deployment after patching. Use PatchLiteLLMProvider + +// RestartLiteLLM to batch multiple providers with a single restart. func ConfigureLiteLLM(cfg *config.Config, u *ui.UI, provider, apiKey string, models []string) error { + if err := PatchLiteLLMProvider(cfg, u, provider, apiKey, models); err != nil { + return err + } + return RestartLiteLLM(cfg, u, provider) +} + +// PatchLiteLLMProvider patches the LiteLLM Secret (API key) and ConfigMap +// (model_list) for a provider without restarting the deployment. Call +// RestartLiteLLM afterwards (once, after batching multiple providers). +func PatchLiteLLMProvider(cfg *config.Config, u *ui.UI, provider, apiKey string, models []string) error { kubectlBinary := filepath.Join(cfg.BinDir, "kubectl") kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") @@ -191,14 +203,20 @@ func ConfigureLiteLLM(cfg *config.Config, u *ui.UI, provider, apiKey string, mod return fmt.Errorf("failed to update LiteLLM config: %w", err) } - // 4. Restart deployment + return nil +} + +// RestartLiteLLM restarts the LiteLLM deployment and waits for rollout. +func RestartLiteLLM(cfg *config.Config, u *ui.UI, provider string) error { + kubectlBinary := filepath.Join(cfg.BinDir, "kubectl") + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + u.Info("Restarting LiteLLM") if err := kubectl.Run(kubectlBinary, kubeconfigPath, "rollout", "restart", fmt.Sprintf("deployment/%s", deployName), "-n", namespace); err != nil { return fmt.Errorf("failed to restart LiteLLM: %w", err) } - // 5. Wait for rollout if err := kubectl.Run(kubectlBinary, kubeconfigPath, "rollout", "status", fmt.Sprintf("deployment/%s", deployName), "-n", namespace, "--timeout=90s"); err != nil { diff --git a/internal/stack/stack.go b/internal/stack/stack.go index a8348864..adb350d2 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -482,12 +482,15 @@ func syncDefaults(cfg *config.Config, u *ui.UI, kubeconfigPath string, dataDir s // autoConfigureLLM detects host Ollama and imported cloud providers, then // auto-configures LiteLLM so inference works out of the box. +// Patches all providers first, then does a single restart. func autoConfigureLLM(cfg *config.Config, u *ui.UI) { - // --- Ollama auto-configuration --- + var configured []string // provider names that were patched + + // --- Ollama --- ollamaModels, err := model.ListOllamaModels() if err == nil && len(ollamaModels) > 0 && !model.HasConfiguredModels(cfg) { u.Blank() - u.Infof("Ollama detected with %d model(s) — auto-configuring LiteLLM", len(ollamaModels)) + u.Infof("Ollama detected with %d model(s)", len(ollamaModels)) var names []string for _, m := range ollamaModels { @@ -498,29 +501,40 @@ func autoConfigureLLM(cfg *config.Config, u *ui.UI) { names = append(names, name) } - if err := model.ConfigureLiteLLM(cfg, u, "ollama", "", names); err != nil { - u.Warnf("Auto-configure LiteLLM failed: %v", err) - u.Dim(" Run 'obol model setup' to configure manually.") + if err := model.PatchLiteLLMProvider(cfg, u, "ollama", "", names); err != nil { + u.Warnf("Auto-configure Ollama failed: %v", err) + } else { + configured = append(configured, "ollama") } } - // --- Cloud provider auto-configuration from ~/.openclaw --- - autoConfigureCloudProviders(cfg, u) + // --- Cloud provider from ~/.openclaw --- + if cloudProvider := autoDetectCloudProvider(cfg, u); cloudProvider != "" { + configured = append(configured, cloudProvider) + } + + // --- Single restart for all providers --- + if len(configured) > 0 { + label := strings.Join(configured, " + ") + if err := model.RestartLiteLLM(cfg, u, label); err != nil { + u.Warnf("LiteLLM restart failed: %v", err) + u.Dim(" Run 'obol model setup' to configure manually.") + } + } } -// autoConfigureCloudProviders reads the imported ~/.openclaw config and, if a -// cloud model is the agent's primary model, auto-configures LiteLLM with the -// matching provider when an API key is available in the environment (or .env -// in dev mode). -func autoConfigureCloudProviders(cfg *config.Config, u *ui.UI) { +// autoDetectCloudProvider reads ~/.openclaw config, resolves the cloud +// provider API key, and patches LiteLLM (without restart). Returns the +// provider name on success, or "" if nothing was configured. +func autoDetectCloudProvider(cfg *config.Config, u *ui.UI) string { imported, err := openclaw.DetectExistingConfig() if err != nil || imported == nil { - return + return "" } agentModel := imported.AgentModel if agentModel == "" { - return + return "" } // Extract provider and model name from "anthropic/claude-sonnet-4-6". @@ -534,17 +548,17 @@ func autoConfigureCloudProviders(cfg *config.Config, u *ui.UI) { } if provider == "" || provider == "ollama" { - return + return "" } // Already configured — skip. if model.HasProviderConfigured(cfg, provider) { - return + return "" } envVar := model.ProviderEnvVar(provider) if envVar == "" { - return + return "" } // Resolve API key: environment first, .env in dev mode only. @@ -559,16 +573,19 @@ func autoConfigureCloudProviders(cfg *config.Config, u *ui.UI) { u.Warnf("Agent model %s detected but %s is not set", agentModel, envVar) u.Dim(fmt.Sprintf(" Set it in your environment: export %s=...", envVar)) u.Dim(fmt.Sprintf(" Or configure after startup: obol model setup --provider %s", provider)) - return + return "" } u.Blank() - u.Infof("Cloud model %s detected — auto-configuring LiteLLM with %s provider", agentModel, provider) + u.Infof("Cloud model %s detected — configuring %s provider", agentModel, provider) - if err := model.ConfigureLiteLLM(cfg, u, provider, apiKey, []string{modelName}); err != nil { - u.Warnf("Auto-configure LiteLLM for %s failed: %v", provider, err) + if err := model.PatchLiteLLMProvider(cfg, u, provider, apiKey, []string{modelName}); err != nil { + u.Warnf("Auto-configure %s failed: %v", provider, err) u.Dim(fmt.Sprintf(" Run 'obol model setup --provider %s' to configure manually.", provider)) + return "" } + + return provider } // localImage describes a Docker image built from source in this repo. From 9d73fa23f73067dead7cd926e0238b063a027e75 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Tue, 17 Mar 2026 16:43:25 +0800 Subject: [PATCH 07/14] ux: prompt for cloud API key interactively in obolup.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of printing a warning that users miss, prompt for the API key during setup when a cloud model is detected in ~/.openclaw config. The key is exported so the subsequent obol bootstrap → stack up → autoConfigureLLM picks it up automatically. Falls back to a warning in non-interactive mode. Inspired by hermes-agent's interactive setup wizard pattern. --- obolup.sh | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/obolup.sh b/obolup.sh index b8ec7078..6b237142 100755 --- a/obolup.sh +++ b/obolup.sh @@ -1497,7 +1497,8 @@ configure_path() { # Print post-install instructions # Check if ~/.openclaw/openclaw.json specifies a cloud model that needs an API key. -# Prints guidance before the "start cluster" prompt so the user can set the key first. +# If the key is missing and we have a TTY, prompt for it interactively and export +# it so the subsequent obol bootstrap / stack up picks it up via autoConfigureLLM. check_agent_model_api_key() { local config_file="$HOME/.openclaw/openclaw.json" [[ -f "$config_file" ]] || return 0 @@ -1517,18 +1518,35 @@ except: pass [[ -n "$primary_model" ]] || return 0 # Determine provider and required env var - local provider="" env_var="" + local provider="" env_var="" provider_name="" case "$primary_model" in - *claude*) provider="anthropic"; env_var="ANTHROPIC_API_KEY" ;; - gpt*|o1*|o3*) provider="openai"; env_var="OPENAI_API_KEY" ;; + *claude*) provider="anthropic"; env_var="ANTHROPIC_API_KEY"; provider_name="Anthropic" ;; + gpt*|o1*|o3*|o4*) provider="openai"; env_var="OPENAI_API_KEY"; provider_name="OpenAI" ;; *) return 0 ;; esac echo "" if [[ -n "${!env_var:-}" ]]; then log_success "$env_var detected for $primary_model" + return 0 + fi + + # Interactive: prompt for the API key (like hermes-agent's setup wizard) + if [[ -c /dev/tty ]]; then + log_info "Your agent uses $primary_model ($provider_name)" + echo "" + local api_key="" + read -r -p " $provider_name API key ($env_var): " api_key Date: Tue, 17 Mar 2026 17:14:10 +0800 Subject: [PATCH 08/14] feat: multi-env credential detection (CLAUDE_CODE_OAUTH_TOKEN) Add AltEnvVars to ProviderInfo and ResolveAPIKey() that tries primary env var then fallbacks in order. Anthropic now checks ANTHROPIC_API_KEY first, then CLAUDE_CODE_OAUTH_TOKEN (Claude Code subscription). autoDetectCloudProvider uses ResolveAPIKey for env resolution and shows which env var was used when it's an alternate. --- internal/model/model.go | 32 +++++++++++++-- internal/model/model_test.go | 75 ++++++++++++++++++++++++++++++++++++ internal/stack/stack.go | 33 +++++++++------- 3 files changed, 122 insertions(+), 18 deletions(-) diff --git a/internal/model/model.go b/internal/model/model.go index a4247527..ce009983 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -28,16 +28,17 @@ const ( // Known provider definitions — no need to query the running pod. var knownProviders = []ProviderInfo{ - {ID: "anthropic", Name: "Anthropic", EnvVar: "ANTHROPIC_API_KEY"}, + {ID: "anthropic", Name: "Anthropic", EnvVar: "ANTHROPIC_API_KEY", AltEnvVars: []string{"CLAUDE_CODE_OAUTH_TOKEN"}}, {ID: "openai", Name: "OpenAI", EnvVar: "OPENAI_API_KEY"}, {ID: "ollama", Name: "Ollama (local)", EnvVar: ""}, } // ProviderInfo describes an LLM provider. type ProviderInfo struct { - ID string // provider id (e.g. "anthropic", "openai", "ollama") - Name string // display name - EnvVar string // env var for API key (empty for Ollama) + ID string // provider id (e.g. "anthropic", "openai", "ollama") + Name string // display name + EnvVar string // primary env var for API key (empty for Ollama) + AltEnvVars []string // fallback env vars checked in order (e.g. CLAUDE_CODE_OAUTH_TOKEN) } // ProviderStatus captures effective global LiteLLM provider state. @@ -656,6 +657,29 @@ func ProviderFromModelName(name string) string { // --- Internal helpers --- +// ResolveAPIKey checks the primary env var and each AltEnvVar in order for +// the given provider. Returns the key value and the env var it was found in. +// Both are empty if no key is available. +func ResolveAPIKey(provider string) (key, envVarUsed string) { + for _, p := range knownProviders { + if p.ID != provider { + continue + } + if p.EnvVar != "" { + if v := os.Getenv(p.EnvVar); v != "" { + return v, p.EnvVar + } + } + for _, alt := range p.AltEnvVars { + if v := os.Getenv(alt); v != "" { + return v, alt + } + } + return "", "" + } + return "", "" +} + // ProviderEnvVar returns the env var name for a provider's API key. func ProviderEnvVar(provider string) string { for _, p := range knownProviders { diff --git a/internal/model/model_test.go b/internal/model/model_test.go index c0d8d604..94cab3f1 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -202,6 +202,81 @@ general_settings: }) } +func TestResolveAPIKey(t *testing.T) { + t.Run("primary env var found", func(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "sk-ant-primary") + t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "") + key, envVar := ResolveAPIKey("anthropic") + if key != "sk-ant-primary" { + t.Errorf("key = %q, want sk-ant-primary", key) + } + if envVar != "ANTHROPIC_API_KEY" { + t.Errorf("envVar = %q, want ANTHROPIC_API_KEY", envVar) + } + }) + + t.Run("fallback env var found", func(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-token-123") + key, envVar := ResolveAPIKey("anthropic") + if key != "oauth-token-123" { + t.Errorf("key = %q, want oauth-token-123", key) + } + if envVar != "CLAUDE_CODE_OAUTH_TOKEN" { + t.Errorf("envVar = %q, want CLAUDE_CODE_OAUTH_TOKEN", envVar) + } + }) + + t.Run("primary takes precedence over fallback", func(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "sk-ant-primary") + t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-token-123") + key, envVar := ResolveAPIKey("anthropic") + if key != "sk-ant-primary" { + t.Errorf("key = %q, want sk-ant-primary (primary should win)", key) + } + if envVar != "ANTHROPIC_API_KEY" { + t.Errorf("envVar = %q, want ANTHROPIC_API_KEY", envVar) + } + }) + + t.Run("neither found", func(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "") + key, envVar := ResolveAPIKey("anthropic") + if key != "" { + t.Errorf("key = %q, want empty", key) + } + if envVar != "" { + t.Errorf("envVar = %q, want empty", envVar) + } + }) + + t.Run("provider with no alt env vars", func(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "sk-openai-123") + key, envVar := ResolveAPIKey("openai") + if key != "sk-openai-123" { + t.Errorf("key = %q, want sk-openai-123", key) + } + if envVar != "OPENAI_API_KEY" { + t.Errorf("envVar = %q, want OPENAI_API_KEY", envVar) + } + }) + + t.Run("ollama returns empty", func(t *testing.T) { + key, envVar := ResolveAPIKey("ollama") + if key != "" || envVar != "" { + t.Errorf("ollama should return empty, got key=%q envVar=%q", key, envVar) + } + }) + + t.Run("unknown provider returns empty", func(t *testing.T) { + key, envVar := ResolveAPIKey("unknown-provider") + if key != "" || envVar != "" { + t.Errorf("unknown provider should return empty, got key=%q envVar=%q", key, envVar) + } + }) +} + func TestProviderEnvVar(t *testing.T) { if got := ProviderEnvVar("anthropic"); got != "ANTHROPIC_API_KEY" { t.Errorf("got %q, want ANTHROPIC_API_KEY", got) diff --git a/internal/stack/stack.go b/internal/stack/stack.go index adb350d2..b7e22101 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -433,10 +433,10 @@ func syncDefaults(cfg *config.Config, u *ui.UI, kubeconfigPath string, dataDir s u.Success("Default infrastructure deployed") - // Auto-configure LiteLLM with Ollama models if available. - // This ensures the inference path works out of the box when the user - // has Ollama running — no separate `obol model setup` step required. - // Non-fatal: the user can always run `obol model setup` later. + // Auto-configure LiteLLM with Ollama models and any cloud providers + // whose API keys are found in the environment. This ensures the + // inference path works out of the box — no separate `obol model setup` + // step required. Non-fatal: the user can always run `obol model setup` later. autoConfigureLLM(cfg, u) // Deploy default OpenClaw instance (non-fatal on failure). @@ -556,28 +556,32 @@ func autoDetectCloudProvider(cfg *config.Config, u *ui.UI) string { return "" } - envVar := model.ProviderEnvVar(provider) - if envVar == "" { - return "" - } - - // Resolve API key: environment first, .env in dev mode only. - apiKey := os.Getenv(envVar) + // Resolve API key: try primary + alt env vars, then .env in dev mode. + apiKey, envVarUsed := model.ResolveAPIKey(provider) if apiKey == "" && os.Getenv("OBOL_DEVELOPMENT") == "true" { + envVar := model.ProviderEnvVar(provider) dotEnv := model.LoadDotEnv(filepath.Join(".", ".env")) apiKey = dotEnv[envVar] + if apiKey != "" { + envVarUsed = envVar + " (.env)" + } } if apiKey == "" { u.Blank() - u.Warnf("Agent model %s detected but %s is not set", agentModel, envVar) - u.Dim(fmt.Sprintf(" Set it in your environment: export %s=...", envVar)) + primaryEnv := model.ProviderEnvVar(provider) + u.Warnf("Agent model %s detected but %s is not set", agentModel, primaryEnv) + u.Dim(fmt.Sprintf(" Set it in your environment: export %s=...", primaryEnv)) u.Dim(fmt.Sprintf(" Or configure after startup: obol model setup --provider %s", provider)) return "" } u.Blank() - u.Infof("Cloud model %s detected — configuring %s provider", agentModel, provider) + if envVarUsed != model.ProviderEnvVar(provider) { + u.Infof("Cloud model %s detected via %s — configuring %s provider", agentModel, envVarUsed, provider) + } else { + u.Infof("Cloud model %s detected — configuring %s provider", agentModel, provider) + } if err := model.PatchLiteLLMProvider(cfg, u, provider, apiKey, []string{modelName}); err != nil { u.Warnf("Auto-configure %s failed: %v", provider, err) @@ -588,6 +592,7 @@ func autoDetectCloudProvider(cfg *config.Config, u *ui.UI) string { return provider } + // localImage describes a Docker image built from source in this repo. type localImage struct { tag string // e.g. "ghcr.io/obolnetwork/x402-verifier:latest" From 94c5ff7cf183c54b6bd319f86fcc0ef21f303601 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Tue, 17 Mar 2026 17:14:20 +0800 Subject: [PATCH 09/14] feat: unify OpenClaw version management with go:embed + consistency test OPENCLAW_VERSION file is the single source of truth. Go code reads it via //go:embed, obolup.sh has its own constant (updated in same PR). TestOpenClawVersionConsistency catches drift between all three. --- internal/openclaw/OPENCLAW_VERSION | 2 +- internal/openclaw/openclaw.go | 29 +++++++++++--- internal/openclaw/version_test.go | 62 ++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 internal/openclaw/version_test.go diff --git a/internal/openclaw/OPENCLAW_VERSION b/internal/openclaw/OPENCLAW_VERSION index 2c973312..a4a00969 100644 --- a/internal/openclaw/OPENCLAW_VERSION +++ b/internal/openclaw/OPENCLAW_VERSION @@ -1,3 +1,3 @@ # renovate: datasource=github-releases depName=openclaw/openclaw # Pins the upstream OpenClaw version to build and publish. -v2026.3.11 +v2026.3.13-1 diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go index a2896afd..04c06316 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + _ "embed" "encoding/base64" "encoding/json" "fmt" @@ -46,15 +47,31 @@ const ( // renovate: datasource=helm depName=openclaw registryUrl=https://obolnetwork.github.io/helm-charts/ chartVersion = "0.1.7" - // openclawImageTag overrides the chart's default image tag. - // Must match the version in OPENCLAW_VERSION (without "v" prefix). - openclawImageTag = "2026.3.13-1" - // remoteSignerChartVersion pins the remote-signer Helm chart version. // renovate: datasource=helm depName=remote-signer registryUrl=https://obolnetwork.github.io/helm-charts/ remoteSignerChartVersion = "0.3.0" ) +// openclawVersionRaw is the single source of truth for the upstream OpenClaw +// version. It is read by CI (docker-publish-openclaw.yml), obolup.sh, and +// the Go binary at compile time via go:embed. +// +//go:embed OPENCLAW_VERSION +var openclawVersionRaw string + +// openclawImageTag returns the image tag derived from OPENCLAW_VERSION, +// stripping the leading "v" and any whitespace/comments. +func openclawImageTag() string { + for _, line := range strings.Split(openclawVersionRaw, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + return strings.TrimPrefix(line, "v") + } + return "" +} + // OnboardOptions contains options for the onboard command type OnboardOptions struct { ID string // Deployment ID (empty = generate petname) @@ -1733,8 +1750,8 @@ rbac: `) // Override chart default image tag when the binary pins a newer version. - if openclawImageTag != "" { - b.WriteString(fmt.Sprintf("# Override chart default image tag (chart ships %s)\nimage:\n tag: \"%s\"\n\n", chartVersion, openclawImageTag)) + if tag := openclawImageTag(); tag != "" { + b.WriteString(fmt.Sprintf("# Override chart default image tag (chart ships %s)\nimage:\n tag: \"%s\"\n\n", chartVersion, tag)) } // Provider and agent model configuration. diff --git a/internal/openclaw/version_test.go b/internal/openclaw/version_test.go new file mode 100644 index 00000000..8ce7b956 --- /dev/null +++ b/internal/openclaw/version_test.go @@ -0,0 +1,62 @@ +package openclaw + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +// TestOpenClawVersionConsistency ensures the three OpenClaw version sources +// stay in sync: +// +// 1. internal/openclaw/OPENCLAW_VERSION — single source of truth (Renovate watches this) +// 2. openclawImageTag() in Go — read via go:embed from the same file +// 3. obolup.sh OPENCLAW_VERSION — shell constant for standalone installs +// +// If this test fails, update all three in the same commit. +func TestOpenClawVersionConsistency(t *testing.T) { + // 1. Read the canonical version file. + _, thisFile, _, _ := runtime.Caller(0) + versionFile := filepath.Join(filepath.Dir(thisFile), "OPENCLAW_VERSION") + raw, err := os.ReadFile(versionFile) + if err != nil { + t.Fatalf("cannot read OPENCLAW_VERSION: %v", err) + } + var fileVersion string + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fileVersion = strings.TrimPrefix(line, "v") + break + } + if fileVersion == "" { + t.Fatal("OPENCLAW_VERSION file has no version line") + } + + // 2. Check the Go embedded version matches. + goTag := openclawImageTag() + if goTag != fileVersion { + t.Errorf("openclawImageTag() = %q, want %q (from OPENCLAW_VERSION)", goTag, fileVersion) + } + + // 3. Check obolup.sh constant matches. + obolupPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "obolup.sh") + obolupRaw, err := os.ReadFile(obolupPath) + if err != nil { + t.Fatalf("cannot read obolup.sh: %v", err) + } + re := regexp.MustCompile(`(?m)^readonly OPENCLAW_VERSION="([^"]+)"`) + matches := re.FindSubmatch(obolupRaw) + if matches == nil { + t.Fatal("obolup.sh does not contain OPENCLAW_VERSION constant") + } + shellVersion := string(matches[1]) + if shellVersion != fileVersion { + t.Errorf("obolup.sh OPENCLAW_VERSION = %q, want %q (from OPENCLAW_VERSION file)", shellVersion, fileVersion) + } +} From 3222bece470732016704ff1b15640f4b67f8d4d5 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Tue, 17 Mar 2026 17:36:54 +0800 Subject: [PATCH 10/14] chore: bump frontend to v0.1.13-rc.2 --- internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl b/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl index 043efb8b..50f3ce95 100644 --- a/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl +++ b/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl @@ -35,7 +35,7 @@ image: repository: obolnetwork/obol-stack-front-end pullPolicy: IfNotPresent - tag: "v0.1.13-rc.1" + tag: "v0.1.13-rc.2" service: type: ClusterIP From 82d413510972d984d35a14aba5ff4795dfe19f51 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Tue, 17 Mar 2026 18:51:47 +0800 Subject: [PATCH 11/14] fix: use green (log_success) for model setup prompt in obolup.sh --- obolup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obolup.sh b/obolup.sh index 6b237142..d00ccac4 100755 --- a/obolup.sh +++ b/obolup.sh @@ -1533,7 +1533,7 @@ except: pass # Interactive: prompt for the API key (like hermes-agent's setup wizard) if [[ -c /dev/tty ]]; then - log_info "Your agent uses $primary_model ($provider_name)" + log_success "Your agent uses $primary_model ($provider_name)" echo "" local api_key="" read -r -p " $provider_name API key ($env_var): " api_key Date: Tue, 17 Mar 2026 18:54:39 +0800 Subject: [PATCH 12/14] fix: use bright brand green for log_success in obolup.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OBOL_DARK_GREEN (#0F7C76) renders as teal/purple on some terminals. Switch to OBOL_GREEN (#2FE4AB) — the primary brand green — for consistent visual identity across the setup output. --- obolup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obolup.sh b/obolup.sh index d00ccac4..24bb47e1 100755 --- a/obolup.sh +++ b/obolup.sh @@ -74,7 +74,7 @@ log_info() { } log_success() { - echo -e " ${OBOL_DARK_GREEN}${BOLD}✓${NC} $1" + echo -e " ${OBOL_GREEN}${BOLD}✓${NC} $1" } log_warn() { From 83c8d3b371da21d03810a320c3072e9405f3b7da Mon Sep 17 00:00:00 2001 From: bussyjd Date: Tue, 17 Mar 2026 22:40:09 +0800 Subject: [PATCH 13/14] fix: use bright brand green for success style in Go CLI ColorObolDarkGreen (#0F7C76) renders as teal/purple on many terminals. Switch successStyle to ColorObolGreen (#2FE4AB) to match obolup.sh and maintain consistent brand identity across the entire CLI output. --- internal/ui/output.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/output.go b/internal/ui/output.go index eabd304e..01c65a1a 100644 --- a/internal/ui/output.go +++ b/internal/ui/output.go @@ -10,7 +10,7 @@ import ( // Lipgloss auto-degrades to 256/16 colors on older terminals. var ( infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorObolGreen)).Bold(true) - successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorObolDarkGreen)).Bold(true) + successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorObolGreen)).Bold(true) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorObolAmber)).Bold(true) errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorObolRed)).Bold(true) dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorObolMuted)) From b9ba35b31d75673003ac5948c829dc3b8ee44a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= <4981644+OisinKyne@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:24:24 +0000 Subject: [PATCH 14/14] Fixes for fixes (#278) * Fixes for fixes * Lower skill load --- cmd/obol/model.go | 4 +- internal/agent/agent.go | 13 +- internal/embed/embed_skills_test.go | 7 +- .../templates/obol-agent-monetize-rbac.yaml | 8 +- .../values/obol-frontend.yaml.gotmpl | 2 +- .../skills/ethereum-local-wallet/SKILL.md | 2 +- .../embed/skills/frontend-playbook/SKILL.md | 363 ------------- internal/embed/skills/frontend-ux/SKILL.md | 346 ------------- internal/embed/skills/indexing/SKILL.md | 2 +- internal/embed/skills/orchestration/SKILL.md | 323 ------------ internal/embed/skills/qa/SKILL.md | 222 -------- internal/embed/skills/security/SKILL.md | 476 ------------------ internal/embed/skills/ship/SKILL.md | 323 ------------ internal/embed/skills/testing/SKILL.md | 390 -------------- internal/embed/skills/tools/SKILL.md | 187 ------- internal/embed/skills/wallets/SKILL.md | 2 +- .../openclaw/monetize_integration_test.go | 10 +- internal/openclaw/openclaw.go | 19 +- internal/tunnel/agent.go | 2 +- internal/tunnel/tunnel.go | 2 +- internal/x402/bdd_integration_test.go | 6 +- internal/x402/setup.go | 2 +- 22 files changed, 34 insertions(+), 2677 deletions(-) delete mode 100644 internal/embed/skills/frontend-playbook/SKILL.md delete mode 100644 internal/embed/skills/frontend-ux/SKILL.md delete mode 100644 internal/embed/skills/orchestration/SKILL.md delete mode 100644 internal/embed/skills/qa/SKILL.md delete mode 100644 internal/embed/skills/security/SKILL.md delete mode 100644 internal/embed/skills/ship/SKILL.md delete mode 100644 internal/embed/skills/testing/SKILL.md delete mode 100644 internal/embed/skills/tools/SKILL.md diff --git a/cmd/obol/model.go b/cmd/obol/model.go index c52199a4..559ed733 100644 --- a/cmd/obol/model.go +++ b/cmd/obol/model.go @@ -104,7 +104,7 @@ func setupOllama(cfg *config.Config, u *ui.UI, models []string) error { if len(ollamaModels) == 0 { u.Warn("No models pulled in Ollama") u.Print("") - u.Print(" Hint: Pull a model with: ollama pull qwen3.5:9b") + u.Print(" Hint: Pull a model with: ollama pull qwen3.5:4b") u.Print(" Hint: Or run: obol model pull") return fmt.Errorf("Ollama is running but has no models") } @@ -378,7 +378,7 @@ func promptModelPull() (string, error) { desc string } suggestions := []suggestion{ - {"qwen3.5:9b", "5.6 GB", "Strong general-purpose (recommended)"}, + {"qwen3.5:4b", "2.7 GB", "Fast general-purpose (recommended)"}, {"qwen2.5-coder:7b", "4.7 GB", "Code generation"}, {"deepseek-r1:8b", "4.9 GB", "Reasoning"}, {"gemma3:4b", "3.3 GB", "Lightweight, multilingual"}, diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 63b6f602..c3fdc07d 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -110,17 +110,8 @@ func injectHeartbeatFile(cfg *config.Config, u *ui.UI) error { return fmt.Errorf("failed to create heartbeat directory: %w", err) } - content := `## Reconcile -Run: python3 /data/.openclaw/skills/sell/scripts/monetize.py process --all --quick -If output starts with "READY:", reply HEARTBEAT_OK. -Otherwise report the changes. - -## Discover (only if /tmp/.obol-last-discovery is older than 10 minutes or missing) -Run: python3 /data/.openclaw/skills/buy-inference/scripts/buy.py maintain -Then: python3 /data/.openclaw/skills/discovery/scripts/discovery.py search --chain base-sepolia --limit 5 -For NEW x402-enabled agents not already purchased, probe and buy if price is reasonable. -After discovery, run: touch /tmp/.obol-last-discovery -Do NOT buy on every heartbeat. Only buy NEW agents. + content := `Run this single command, then reply with ONLY its output (no commentary): +python3 /data/.openclaw/skills/sell/scripts/monetize.py process --all --quick ` heartbeatPath := filepath.Join(heartbeatDir, "HEARTBEAT.md") diff --git a/internal/embed/embed_skills_test.go b/internal/embed/embed_skills_test.go index e690d930..000f87bf 100644 --- a/internal/embed/embed_skills_test.go +++ b/internal/embed/embed_skills_test.go @@ -17,10 +17,9 @@ func TestGetEmbeddedSkillNames(t *testing.T) { // Core skills that must always be present coreSkills := []string{ - "addresses", "building-blocks", "concepts", "discovery", "distributed-validators", - "ethereum-networks", "ethereum-local-wallet", "frontend-playbook", "frontend-ux", "gas", - "indexing", "l2s", "sell", "obol-stack", "orchestration", "qa", "security", - "ship", "standards", "testing", "tools", "wallets", "why", + "addresses", "building-blocks", "buy-inference", "concepts", "discovery", + "distributed-validators", "ethereum-networks", "ethereum-local-wallet", + "gas", "indexing", "l2s", "sell", "obol-stack", "standards", "wallets", "why", } sort.Strings(names) diff --git a/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml b/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml index 72d7fc38..cc08b09b 100644 --- a/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml +++ b/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml @@ -98,7 +98,7 @@ roleRef: subjects: - kind: ServiceAccount name: openclaw - namespace: openclaw-default + namespace: openclaw-obol-agent --- #------------------------------------------------------------------------------ @@ -115,7 +115,7 @@ roleRef: subjects: - kind: ServiceAccount name: openclaw - namespace: openclaw-default + namespace: openclaw-obol-agent --- @@ -151,7 +151,7 @@ roleRef: subjects: - kind: ServiceAccount name: openclaw - namespace: openclaw-default + namespace: openclaw-obol-agent --- #------------------------------------------------------------------------------ @@ -186,4 +186,4 @@ roleRef: subjects: - kind: ServiceAccount name: openclaw - namespace: openclaw-default + namespace: openclaw-obol-agent diff --git a/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl b/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl index 50f3ce95..a3906036 100644 --- a/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl +++ b/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl @@ -35,7 +35,7 @@ image: repository: obolnetwork/obol-stack-front-end pullPolicy: IfNotPresent - tag: "v0.1.13-rc.2" + tag: "v0.1.14" service: type: ClusterIP diff --git a/internal/embed/skills/ethereum-local-wallet/SKILL.md b/internal/embed/skills/ethereum-local-wallet/SKILL.md index 6ec02d42..e47b6100 100644 --- a/internal/embed/skills/ethereum-local-wallet/SKILL.md +++ b/internal/embed/skills/ethereum-local-wallet/SKILL.md @@ -1,6 +1,6 @@ --- name: ethereum-local-wallet -description: "Sign and send Ethereum transactions via the local remote-signer. Use when asked to send ETH, sign messages, approve tokens, or interact with smart contracts that modify state. All signing goes through the in-cluster remote-signer; agents never touch private key material." +description: "Execute Ethereum transactions NOW — send ETH, approve tokens, call contracts, sign messages. Uses the in-cluster remote-signer (agents never touch private keys). Use this skill whenever the user wants to DO something onchain, not just learn about wallets." metadata: { "openclaw": { "emoji": "💳", "requires": { "bins": ["python3"] } } } --- diff --git a/internal/embed/skills/frontend-playbook/SKILL.md b/internal/embed/skills/frontend-playbook/SKILL.md deleted file mode 100644 index 102128f7..00000000 --- a/internal/embed/skills/frontend-playbook/SKILL.md +++ /dev/null @@ -1,363 +0,0 @@ ---- -name: frontend-playbook -description: The complete build-to-production pipeline for Ethereum dApps. Fork mode setup, IPFS deployment, Vercel config, ENS subdomain setup, and the full production checklist. Built around Scaffold-ETH 2 but applicable to any Ethereum frontend project. Use when deploying any dApp to production. ---- - -# Frontend Playbook - -## What You Probably Got Wrong - -**"I'll use `yarn chain`."** Wrong. `yarn chain` gives you an empty local chain with no protocols, no tokens, no state. `yarn fork --network base` gives you a copy of real Base with Uniswap, Aave, USDC, real whale balances — everything. Always fork. - -**"I deployed to IPFS and it works."** Did the CID change? If not, you deployed stale output. Did routes work? Without `trailingSlash: true`, every route except `/` returns 404. Did you check the OG image? Without `NEXT_PUBLIC_PRODUCTION_URL`, it points to `localhost:3000`. - -**"I'll set up the project manually."** Don't. `npx create-eth@latest` handles everything — Foundry, Next.js, RainbowKit, scaffold hooks. Never run `forge init` or create Next.js projects from scratch. - ---- - -## Fork Mode Setup - -### Why Fork, Not Chain - -``` -yarn chain (WRONG) yarn fork --network base (CORRECT) -└─ Empty local chain └─ Fork of real Base mainnet -└─ No protocols └─ Uniswap, Aave, etc. available -└─ No tokens └─ Real USDC, WETH exist -└─ Testing in isolation └─ Test against REAL state -``` - -### Setup - -```bash -npx create-eth@latest # Select: foundry, target chain, name -cd -yarn install -yarn fork --network base # Terminal 1: fork of real Base -yarn deploy # Terminal 2: deploy contracts to fork -yarn start # Terminal 3: Next.js frontend -``` - -### Critical: Chain ID Gotcha - -**When using fork mode, the frontend target network MUST be `chains.foundry` (chain ID 31337), NOT the chain you're forking.** - -The fork runs locally on Anvil with chain ID 31337. Even if you're forking Base: - -```typescript -// scaffold.config.ts during development -targetNetworks: [chains.foundry], // ✅ NOT chains.base! -``` - -Only switch to `chains.base` when deploying contracts to the REAL network. - -### Enable Block Mining - -```bash -# In a new terminal — REQUIRED for time-dependent logic -cast rpc anvil_setIntervalMining 1 -``` - -Without this, `block.timestamp` stays FROZEN. Any contract logic using timestamps (deadlines, expiry, vesting) will break silently. - -**Make it permanent** by editing `packages/foundry/package.json` to add `--block-time 1` to the fork script. - ---- - -## Deploying to IPFS (Recommended) - -IPFS is the recommended deploy path for SE2. Avoids Vercel's memory limits entirely. Produces a fully decentralized static site. - -### Full Build Command - -```bash -cd packages/nextjs -rm -rf .next out # ALWAYS clean first - -NEXT_PUBLIC_PRODUCTION_URL="https://yourapp.yourname.eth.link" \ - NODE_OPTIONS="--require ./polyfill-localstorage.cjs" \ - NEXT_PUBLIC_IPFS_BUILD=true \ - NEXT_PUBLIC_IGNORE_BUILD_ERROR=true \ - yarn build - -# Upload to BuidlGuidl IPFS -yarn bgipfs upload out -# Save the CID! -``` - -### Node 25+ localStorage Polyfill (REQUIRED) - -Node.js 25+ ships a built-in `localStorage` object that's MISSING standard WebStorage API methods (`getItem`, `setItem`). This breaks `next-themes`, RainbowKit, and any library that calls `localStorage.getItem()` during static page generation. - -**Error you'll see:** -``` -TypeError: localStorage.getItem is not a function -Error occurred prerendering page "/_not-found" -``` - -**The fix:** Create `polyfill-localstorage.cjs` in `packages/nextjs/`: -```javascript -if (typeof globalThis.localStorage !== "undefined" && - typeof globalThis.localStorage.getItem !== "function") { - const store = new Map(); - globalThis.localStorage = { - getItem: (key) => store.get(key) ?? null, - setItem: (key, value) => store.set(key, String(value)), - removeItem: (key) => store.delete(key), - clear: () => store.clear(), - key: (index) => [...store.keys()][index] ?? null, - get length() { return store.size; }, - }; -} -``` - -**Why `--require` and not `instrumentation.ts`?** Next.js spawns a separate build worker process for prerendering. `--require` injects into EVERY Node process (including workers). `next.config.ts` polyfill only runs in the main process. `instrumentation.ts` doesn't run in the build worker. Only `--require` works. - -### IPFS Routing — Why Routes Break - -IPFS gateways serve static files. No server handles routing. Three things MUST be true: - -**1. `output: "export"` in next.config.ts** — generates static HTML files. - -**2. `trailingSlash: true` (CRITICAL)** — This is the #1 reason routes break: -- `trailingSlash: false` (default) → generates `debug.html` -- `trailingSlash: true` → generates `debug/index.html` -- IPFS gateways resolve directories to `index.html` automatically, but NOT bare filenames -- Without trailing slash: `/debug` → 404 ❌ -- With trailing slash: `/debug` → `debug/` → `debug/index.html` ✅ - -**3. Pages must survive static prerendering** — any page that crashes during `yarn build` (browser APIs at import time, localStorage) gets skipped silently → 404 on IPFS. - -**The complete IPFS-safe next.config.ts pattern:** -```typescript -const isIpfs = process.env.NEXT_PUBLIC_IPFS_BUILD === "true"; -if (isIpfs) { - nextConfig.output = "export"; - nextConfig.trailingSlash = true; - nextConfig.images = { unoptimized: true }; -} -``` - -**SE2's block explorer pages** use `localStorage` at import time and crash during static export. Rename `app/blockexplorer` to `app/_blockexplorer-disabled` if not needed. - -### Stale Build Detection - -**The #1 IPFS footgun:** You edit code, then deploy the OLD build. - -```bash -# MANDATORY after ANY code change: -rm -rf .next out # 1. Delete old artifacts -# ... run full build command ... # 2. Rebuild from scratch -grep -l "YOUR_STRING" out/_next/static/chunks/app/*.js # 3. Verify changes present - -# Timestamp check: -stat -f '%Sm' app/page.tsx # Source modified time -stat -f '%Sm' out/ # Build output time -# Source NEWER than out/ = STALE BUILD. Rebuild first! -``` - -**The CID is proof:** If the IPFS CID didn't change after a deploy, you deployed the same content. A real code change ALWAYS produces a new CID. - -### Verify Routes After Deploy - -```bash -ls out/*/index.html # Each route has a directory + index.html -curl -s -o /dev/null -w "%{http_code}" -L "https://GATEWAY/ipfs/CID/debug/" -# Should return 200, not 404 -``` - ---- - -## Deploying to Vercel (Alternative) - -SE2 is a monorepo — Vercel needs special configuration. - -### Configuration - -1. **Root Directory:** `packages/nextjs` -2. **Install Command:** `cd ../.. && yarn install` -3. **Build Command:** leave default (`next build`) -4. **Output Directory:** leave default (`.next`) - -```bash -# Via API: -curl -X PATCH "https://api.vercel.com/v9/projects/PROJECT_ID" \ - -H "Authorization: Bearer $VERCEL_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"rootDirectory": "packages/nextjs", "installCommand": "cd ../.. && yarn install"}' -``` - -### Common Failures - -| Error | Cause | Fix | -|-------|-------|-----| -| "No Next.js version detected" | Root Directory not set | Set to `packages/nextjs` | -| "cd packages/nextjs: No such file" | Build command has `cd` | Clear it — root dir handles this | -| OOM / exit code 129 | SE2 monorepo exceeds 8GB | Use IPFS instead, or `vercel --prebuilt` | - -### Decision Tree - -``` -Want to deploy SE2? -├─ IPFS (recommended) → yarn ipfs / manual build + upload -│ └─ Fully decentralized, no memory limits, works with ENS -├─ Vercel → Set rootDirectory + installCommand -│ └─ Fast CDN, but centralized. May OOM on large projects -└─ vercel --prebuilt → Build locally, push artifacts to Vercel - └─ Best of both: local build power + Vercel CDN -``` - ---- - -## ENS Subdomain Setup - -Two mainnet transactions to point an ENS subdomain at your IPFS deployment. - -### Transaction 1: Create Subdomain (new apps only) - -1. Open `https://app.ens.domains/yourname.eth` -2. Go to "Subnames" tab → "New subname" -3. Enter the label (e.g. `myapp`) → Next → Skip profile → Open Wallet → Confirm -4. If gas is stuck: switch MetaMask to Ethereum → Activity tab → "Speed up" - -### Transaction 2: Set IPFS Content Hash - -1. Navigate to `https://app.ens.domains/myapp.yourname.eth` -2. "Records" tab → "Edit Records" → "Other" tab -3. Paste in Content Hash field: `ipfs://` -4. Save → Open Wallet → Confirm in MetaMask - -For **updates** to an existing app: skip Tx 1, only do Tx 2. - -### Verify - -```bash -# 1. Onchain content hash matches -ERPC="http://erpc.erpc.svc.cluster.local/rpc/mainnet" # or https://eth.llamarpc.com -RESOLVER=$(cast call 0x00000000000C2e074eC69A0dFb2997BA6C7d2e1e \ - "resolver(bytes32)(address)" $(cast namehash myapp.yourname.eth) \ - --rpc-url $ERPC) -cast call $RESOLVER "contenthash(bytes32)(bytes)" \ - $(cast namehash myapp.yourname.eth) --rpc-url $ERPC - -# 2. Gateway responds (may take 5-15 min for cache) -curl -s -o /dev/null -w "%{http_code}" -L "https://myapp.yourname.eth.link" - -# 3. OG metadata correct (not localhost) -curl -s -L "https://myapp.yourname.eth.link" | grep 'og:image' -``` - -**Use `.eth.link` NOT `.eth.limo`** — `.eth.link` works better on mobile. - ---- - -## Go to Production — Complete Checklist - -When the user says "ship it", follow this EXACT sequence. - -### Step 1: Final Code Review 🤖 -- All feedback incorporated -- No duplicate h1, no raw addresses, no shared isLoading -- `scaffold.config.ts` has `rpcOverrides` and `pollingInterval: 3000` - -### Step 2: Choose Domain 👤 -Ask: *"What subdomain do you want? e.g. `myapp.yourname.eth` → `myapp.yourname.eth.link`"* - -### Step 3: Generate OG Image + Fix Metadata 🤖 -- Create 1200×630 PNG (`public/thumbnail.png`) — NOT the stock SE2 thumbnail -- Set `NEXT_PUBLIC_PRODUCTION_URL` to the live domain -- Verify `og:image` will resolve to an absolute production URL - -### Step 4: Clean Build + IPFS Deploy 🤖 -```bash -cd packages/nextjs && rm -rf .next out -NEXT_PUBLIC_PRODUCTION_URL="https://myapp.yourname.eth.link" \ - NODE_OPTIONS="--require ./polyfill-localstorage.cjs" \ - NEXT_PUBLIC_IPFS_BUILD=true NEXT_PUBLIC_IGNORE_BUILD_ERROR=true \ - yarn build - -# Verify before uploading: -ls out/*/index.html # Routes exist -grep 'og:image' out/index.html # Not localhost -stat -f '%Sm' app/page.tsx # Source older than out/ -stat -f '%Sm' out/ - -yarn bgipfs upload out # Save the CID -``` - -### Step 5: Share for Approval 👤 -Send: *"Build ready for review: `https://community.bgipfs.com/ipfs/`"* -**Wait for approval before touching ENS.** - -### Step 6: Set ENS 🤖 -Create subdomain (if new) + set IPFS content hash. Two mainnet transactions. - -### Step 7: Verify 🤖 -- Content hash matches onchain -- `.eth.link` gateway responds with 200 -- OG image loads correctly -- Routes work (`/debug/`, etc.) - -### Step 8: Report 👤 -*"Live at `https://myapp.yourname.eth.link` — ENS content hash confirmed onchain, unfurl metadata set."* - ---- - -## Build Verification Process - -A build is NOT done when the code compiles. It's done when you've tested it like a real user. - -### Phase 1: Code QA (Automated) -- Scan `.tsx` files for raw address strings (should use `
`) -- Scan for shared `isLoading` state across multiple buttons -- Scan for missing `disabled` props on transaction buttons -- Verify RPC config and polling interval -- Verify OG metadata with absolute URLs -- Verify no public RPCs in any file - -### Phase 2: Smart Contract Testing -```bash -forge test # All tests pass -forge test --fuzz-runs 10000 # Fuzz testing -``` -Test edge cases: zero amounts, max amounts, unauthorized callers, reentrancy attempts. - -### Phase 3: Browser Testing (THE REAL TEST) - -Open the app and do a FULL walkthrough: - -1. **Load the app** — does it render correctly? -2. **Check page title** — is it correct, not "Scaffold-ETH 2"? -3. **Connect wallet** — does the connect flow work? -4. **Wrong network** — connect on wrong chain, verify "Switch to Base" appears -5. **Switch network** — click the switch button, verify it works -6. **Approve flow** — verify approve button shows, click it, wait for tx, verify action button appears -7. **Main action** — click primary action, verify loader, wait for tx, verify state updates -8. **Error handling** — reject a transaction in wallet, verify UI recovers -9. **Address displays** — all addresses showing ENS/blockies, not raw hex? -10. **Share URL** — check OG unfurl (image, title, description) - -### Phase 4: QA Sub-Agent (Complex Builds) -For bigger projects, spawn a sub-agent with fresh context. Give it the repo path and deployed URL. It reads all code against the UX rules, opens a browser, clicks through independently, and reports issues. - ---- - -## Don't Do These - -- ❌ `yarn chain` — use `yarn fork --network ` -- ❌ `forge init` — use `npx create-eth@latest` -- ❌ Manual Next.js setup — SE2 handles it -- ❌ Manual wallet connection — SE2 has RainbowKit pre-configured -- ❌ Edit `deployedContracts.ts` — it's auto-generated by `yarn deploy` -- ❌ Hardcode API keys in `scaffold.config.ts` — use `.env.local` -- ❌ Use `mainnet.base.org` in production — use Alchemy or similar - ---- - -## Resources - -- **SE2 Docs:** https://docs.scaffoldeth.io/ -- **UI Components:** https://ui.scaffoldeth.io/ -- **SpeedRun Ethereum:** https://speedrunethereum.com/ -- **ETH Tech Tree:** https://www.ethtechtree.com -- **BuidlGuidl IPFS:** https://upload.bgipfs.com diff --git a/internal/embed/skills/frontend-ux/SKILL.md b/internal/embed/skills/frontend-ux/SKILL.md deleted file mode 100644 index 691e06a9..00000000 --- a/internal/embed/skills/frontend-ux/SKILL.md +++ /dev/null @@ -1,346 +0,0 @@ ---- -name: frontend-ux -description: Frontend UX rules for Ethereum dApps that prevent the most common AI agent UI bugs. Mandatory patterns for onchain buttons, token approval flows, address display, USD values, RPC configuration, and pre-publish metadata. Built around Scaffold-ETH 2 but the patterns apply to any Ethereum frontend. Use when building any dApp frontend. ---- - -# Frontend UX Rules - -## What You Probably Got Wrong - -**"The button works."** Working is not the standard. Does it disable during the transaction? Does it show a spinner? Does it stay disabled until the chain confirms? Does it show an error if the user rejects? AI agents skip all of this, every time. - -**"I used wagmi hooks."** Wrong hooks. Scaffold-ETH 2 wraps wagmi with `useTransactor` which **waits for transaction confirmation** — not just wallet signing. Raw wagmi's `writeContractAsync` resolves the moment the user clicks Confirm in MetaMask, BEFORE the tx is mined. Your button re-enables while the transaction is still pending. - -**"I showed the address."** As raw hex? That's not showing it. `
` gives you ENS resolution, blockie avatars, copy-to-clipboard, and block explorer links. Raw `0x1234...5678` is unacceptable. - ---- - -## Rule 1: Every Onchain Button — Loader + Disable - -> ⚠️ **THIS IS THE #1 BUG AI AGENTS SHIP.** The user clicks Approve, signs in their wallet, comes back to the app, and the Approve button is clickable again — so they click it again, send a duplicate transaction, and now two approvals are pending. **The button MUST be disabled and show a spinner from the moment they click until the transaction confirms onchain.** Not until the wallet closes. Not until the signature is sent. Until the BLOCK CONFIRMS. - -ANY button that triggers a blockchain transaction MUST: -1. **Disable immediately** on click -2. **Show a spinner** ("Approving...", "Staking...", etc.) -3. **Stay disabled** until the state update confirms the action completed -4. **Show success/error feedback** when done - -```typescript -// ✅ CORRECT: Separate loading state PER ACTION -const [isApproving, setIsApproving] = useState(false); -const [isStaking, setIsStaking] = useState(false); - - -``` - -**❌ NEVER use a single shared `isLoading` for multiple buttons.** Each button gets its own loading state. A shared state causes the WRONG loading text to appear when UI conditionally switches between buttons. - -### Scaffold Hooks Only — Never Raw Wagmi - -```typescript -// ❌ WRONG: Raw wagmi — resolves after signing, not confirmation -const { writeContractAsync } = useWriteContract(); -await writeContractAsync({...}); // Returns immediately after MetaMask signs! - -// ✅ CORRECT: Scaffold hooks — waits for tx to be mined -const { writeContractAsync } = useScaffoldWriteContract("MyContract"); -await writeContractAsync({...}); // Waits for actual onchain confirmation -``` - -**Why:** `useScaffoldWriteContract` uses `useTransactor` internally, which waits for block confirmation. Raw wagmi doesn't — your UI will show "success" while the transaction is still in the mempool. - ---- - -## Rule 2: Four-State Flow — Connect → Network → Approve → Action - -When a user needs to interact with the app, there are FOUR states. Show exactly ONE big, obvious button at a time: - -``` -1. Not connected? → Big "Connect Wallet" button (NOT text saying "connect your wallet to play") -2. Wrong network? → Big "Switch to Base" button -3. Not enough approved? → "Approve" button (with loader per Rule 1) -4. Enough approved? → "Stake" / "Deposit" / action button -``` - -> **NEVER show a text prompt like "Connect your wallet to play" or "Please connect to continue."** Show a button. The user should always have exactly one thing to click. - -```typescript -const { data: allowance } = useScaffoldReadContract({ - contractName: "Token", - functionName: "allowance", - args: [address, contractAddress], -}); - -const needsApproval = !allowance || allowance < amount; -const wrongNetwork = chain?.id !== targetChainId; -const notConnected = !address; - -{notConnected ? ( - // Big connect button — NOT text -) : wrongNetwork ? ( - -) : needsApproval ? ( - -) : ( - -)} -``` - -**Critical details:** -- Always read allowance via a hook so the UI updates automatically when the approval tx confirms -- Never rely on local state alone for allowance tracking -- Wrong network check comes FIRST — if the user clicks Approve while on the wrong network, everything breaks -- **Never show Approve and Action simultaneously** — one button at a time - ---- - -## Rule 3: Address Display — Always `
` - -**EVERY time you display an Ethereum address**, use scaffold-eth's `
` component: - -```typescript -import { Address } from "~~/components/scaffold-eth"; - -// ✅ CORRECT -
- -// ❌ WRONG — never render raw hex -{userAddress} -

0x1234...5678

-``` - -`
` handles ENS resolution, blockie avatars, copy-to-clipboard, truncation, and block explorer links. Raw hex is unacceptable. - -### Address Input — Always `` - -**EVERY time the user needs to enter an Ethereum address**, use ``: - -```typescript -import { AddressInput } from "~~/components/scaffold-eth"; - -// ✅ CORRECT - - -// ❌ WRONG — never use a raw text input for addresses - setRecipient(e.target.value)} /> -``` - -`` provides ENS resolution (type "vitalik.eth" → resolves to address), blockie avatar preview, validation, and paste handling. - -**The pair: `
` for DISPLAY, `` for INPUT. Always.** - -### Show Your Contract Address - -**Every dApp should display its deployed contract address** at the bottom of the main page using `
`. Users want to verify the contract on a block explorer. This builds trust and is standard practice. - -```typescript -
-

Contract:

-
-
-``` - ---- - -## Rule 4: USD Values Everywhere - -**EVERY token or ETH amount displayed should include its USD value.** -**EVERY token or ETH input should show a live USD preview.** - -```typescript -// ✅ CORRECT — Display with USD -1,000 TOKEN (~$4.20) -0.5 ETH (~$1,250.00) - -// ✅ CORRECT — Input with live USD preview - - - ≈ ${(parseFloat(amount || "0") * tokenPrice).toFixed(2)} USD - - -// ❌ WRONG — Amount with no USD context -1,000 TOKEN // User has no idea what this is worth -``` - -**Where to get prices:** -- **ETH price:** SE2 built-in hook — `useNativeCurrencyPrice()` -- **Custom tokens:** DexScreener API (`https://api.dexscreener.com/latest/dex/tokens/TOKEN_ADDRESS`), onchain Uniswap quoter, or Chainlink oracle - -**This applies to both display AND input:** -- Displaying a balance? Show USD next to it. -- User entering an amount to send/stake/swap? Show live USD preview below the input. -- Transaction confirmation? Show USD value of what they're about to do. - ---- - -## Rule 5: No Duplicate Titles - -**DO NOT put the app name as an `

` at the top of the page body.** The SE2 header already displays the app name. Repeating it wastes space and looks amateur. - -```typescript -// ❌ WRONG — AI agents ALWAYS do this -
{/* Already shows "🦞 My dApp" */} -
-

🦞 My dApp

{/* DUPLICATE! Delete this. */} -

Description of the app

- ... -
- -// ✅ CORRECT — Jump straight into content -
{/* Shows the app name */} -
-
- {/* Stats, balances, actions — no redundant title */} -
-
-``` - ---- - -## Rule 6: RPC Configuration - -**NEVER use public RPCs** (`mainnet.base.org`, etc.) — they rate-limit and cause random failures in production. - -In `scaffold.config.ts`, ALWAYS set: -```typescript -rpcOverrides: { - [chains.base.id]: process.env.NEXT_PUBLIC_BASE_RPC || "https://mainnet.base.org", -}, -pollingInterval: 3000, // 3 seconds, not the default 30000 -``` - -**Keep the API key in `.env.local`** — never hardcode it in config files that get committed to Git. - -> ⚠️ **SE2's `wagmiConfig.tsx` adds a bare `http()` (no URL) as a fallback transport.** Viem resolves bare `http()` to the chain's default public RPC (e.g. `mainnet.base.org` for Base). Even with `rpcOverrides` set in scaffold config, the public RPC **will still get hit** because viem's `fallback()` fires transports in parallel. **You must remove the bare `http()` from the fallback array in `services/web3/wagmiConfig.tsx`** so only your configured RPCs are used. If you don't, your app will spam the public RPC with every poll cycle and get 429 rate-limited in production. - -**Monitor RPC usage:** Sensible = 1 request every 3 seconds. If you see 15+ requests/second, you have a bug: -- Hooks re-rendering in loops -- Duplicate hook calls -- Missing dependency arrays -- `watch: true` on hooks that don't need it - ---- - -## Rule 7: Pre-Publish Checklist - -**BEFORE deploying frontend to production, EVERY item must pass:** - -**Open Graph / Twitter Cards (REQUIRED):** -```typescript -// In app/layout.tsx or getMetadata.ts -export const metadata: Metadata = { - title: "Your App Name", - description: "Description of the app", - openGraph: { - title: "Your App Name", - description: "Description of the app", - images: [{ url: "https://YOUR-LIVE-DOMAIN.com/thumbnail.png" }], - }, - twitter: { - card: "summary_large_image", - title: "Your App Name", - description: "Description of the app", - images: ["https://YOUR-LIVE-DOMAIN.com/thumbnail.png"], - }, -}; -``` - -**⚠️ The OG image URL MUST be:** -- Absolute URL starting with `https://` -- The LIVE production domain (NOT `localhost`, NOT relative path) -- NOT an environment variable that could be unset -- Actually reachable (test by visiting the URL in a browser) - -**Remove ALL Scaffold-ETH 2 default identity:** -- [ ] README rewritten — not the SE2 template README -- [ ] Footer cleaned — remove BuidlGuidl links, "Fork me" link, support links, any SE2 branding. Replace with your project's repo link -- [ ] Favicon updated — not the SE2 default -- [ ] Tab title is your app name — not "Scaffold-ETH 2" - -**Full checklist:** -- [ ] OG image URL is absolute, live production domain -- [ ] OG title and description set (not default SE2 text) -- [ ] Twitter card type set (`summary_large_image`) -- [ ] All SE2 default branding removed (README, footer, favicon, tab title) -- [ ] Browser tab title is correct -- [ ] RPC overrides set (not public RPCs) -- [ ] Bare `http()` removed from wagmiConfig.tsx fallback array (no silent public RPC fallback) -- [ ] `pollingInterval` is 3000 -- [ ] All contract addresses match what's deployed -- [ ] No hardcoded testnet/localhost values in production code -- [ ] Every address display uses `
` -- [ ] Every address input uses `` -- [ ] Every onchain button has its own loader + disabled state -- [ ] Approve flow has network check → approve → action pattern -- [ ] No duplicate h1 title matching header - ---- - -## externalContracts.ts — Before You Build - -**ALL external contracts** (tokens, protocols, anything you didn't deploy) MUST be added to `packages/nextjs/contracts/externalContracts.ts` with address and ABI BEFORE building the frontend. - -```typescript -// packages/nextjs/contracts/externalContracts.ts -export default { - 8453: { // Base chain ID - USDC: { - address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - abi: [...], // ERC-20 ABI - }, - }, -} as const; -``` - -**Why BEFORE:** Scaffold hooks (`useScaffoldReadContract`, `useScaffoldWriteContract`) only work with contracts registered in `deployedContracts.ts` (auto-generated) or `externalContracts.ts` (manual). If you write frontend code referencing a contract that isn't registered, it silently fails. - -**Never edit `deployedContracts.ts`** — it's auto-generated by `yarn deploy`. Put your external contracts in `externalContracts.ts`. - ---- - -## Human-Readable Amounts - -Always convert between contract units and display units: - -```typescript -// Contract → Display -import { formatEther, formatUnits } from "viem"; -formatEther(weiAmount); // 18 decimals (ETH, DAI, most tokens) -formatUnits(usdcAmount, 6); // 6 decimals (USDC, USDT) - -// Display → Contract -import { parseEther, parseUnits } from "viem"; -parseEther("1.5"); // → 1500000000000000000n -parseUnits("100", 6); // → 100000000n (USDC) -``` - -**Never show raw wei/units to users.** `1500000000000000000` means nothing. `1.5 ETH (~$3,750)` means everything. - ---- - -## Resources - -- **SE2 Docs:** https://docs.scaffoldeth.io/ -- **UI Components:** https://ui.scaffoldeth.io/ -- **SpeedRun Ethereum:** https://speedrunethereum.com/ diff --git a/internal/embed/skills/indexing/SKILL.md b/internal/embed/skills/indexing/SKILL.md index 5958091e..a0271678 100644 --- a/internal/embed/skills/indexing/SKILL.md +++ b/internal/embed/skills/indexing/SKILL.md @@ -1,6 +1,6 @@ --- name: indexing -description: How to read and query onchain data — events, The Graph, indexing patterns. Why you cannot just loop through blocks, and what to use instead. +description: How to read historical onchain data at scale — events, logs, The Graph subgraphs, Ponder, and indexing patterns. Use when building a backend that needs past blockchain state (not live queries — use ethereum-networks for that). Covers why you cannot loop through blocks and what to use instead. --- # Onchain Data & Indexing diff --git a/internal/embed/skills/orchestration/SKILL.md b/internal/embed/skills/orchestration/SKILL.md deleted file mode 100644 index ddb7f71b..00000000 --- a/internal/embed/skills/orchestration/SKILL.md +++ /dev/null @@ -1,323 +0,0 @@ ---- -name: orchestration -description: How an AI agent plans, builds, and deploys a complete Ethereum dApp. The three-phase build system for Scaffold-ETH 2 projects. Use when building a full application on Ethereum — from contracts to frontend to production deployment on IPFS. ---- - -# dApp Orchestration - -## What You Probably Got Wrong - -**SE2 has specific patterns you must follow.** Generic "build a dApp" advice won't work. SE2 auto-generates `deployedContracts.ts` — DON'T edit it. Use Scaffold hooks, NOT raw wagmi. External contracts go in `externalContracts.ts` BEFORE building the frontend. - -**There are three phases. Never skip or combine them.** Contracts → Frontend → Production. Each has validation gates. - -## The Three-Phase Build System - -| Phase | Environment | What Happens | -|-------|-------------|-------------| -| **Phase 1** | Local fork | Contracts + UI on localhost. Iterate fast. | -| **Phase 2** | Live network + local UI | Deploy contracts to mainnet/L2. Test with real state. Polish UI. | -| **Phase 3** | Production | Deploy frontend to IPFS/Vercel. Final QA. | - -## Phase 1: Scaffold (Local) - -### 1.1 Contracts - -```bash -npx create-eth@latest my-dapp -cd my-dapp && yarn install -yarn chain # Terminal 1: local node -yarn deploy # Terminal 2: deploy contracts -``` - -**Critical steps:** -1. Write contracts in `packages/foundry/contracts/` (or `packages/hardhat/contracts/`) -2. Write deploy script -3. Add ALL external contracts to `packages/nextjs/contracts/externalContracts.ts` — BEFORE Phase 1.2 -4. Write tests (≥90% coverage) -5. Security audit before moving to frontend - -**Validate:** `yarn deploy` succeeds. `deployedContracts.ts` auto-generated. Tests pass. - -### 1.2 Frontend - -```bash -yarn chain # Terminal 1 -yarn deploy --watch # Terminal 2: auto-redeploy on changes -yarn start # Terminal 3: Next.js at localhost:3000 -``` - -**USE SCAFFOLD HOOKS, NOT RAW WAGMI:** - -```typescript -// Read -const { data } = useScaffoldReadContract({ - contractName: "YourContract", - functionName: "balanceOf", - args: [address], - watch: true, -}); - -// Write -const { writeContractAsync, isMining } = useScaffoldWriteContract("YourContract"); -await writeContractAsync({ - functionName: "swap", - args: [tokenIn, tokenOut, amount], - onBlockConfirmation: (receipt) => console.log("Done!", receipt), -}); - -// Events -const { data: events } = useScaffoldEventHistory({ - contractName: "YourContract", - eventName: "SwapExecuted", - fromBlock: 0n, - watch: true, -}); -``` - -### The Three-Button Flow (MANDATORY) - -Any token interaction shows ONE button at a time: -1. **Switch Network** (if wrong chain) -2. **Approve Token** (if allowance insufficient) -3. **Execute Action** (only after 1 & 2 satisfied) - -Never show Approve and Execute simultaneously. - -### UX Rules - -- **Human-readable amounts:** `formatEther()` / `formatUnits()` for display, `parseEther()` / `parseUnits()` for contracts -- **Loading states everywhere:** `isLoading`, `isMining` on all async operations -- **Disable buttons during pending txs** (blockchains take 5-12s) -- **Never use infinite approvals** — approve exact amount or 3-5x -- **Helpful errors:** Parse "insufficient funds," "user rejected," "execution reverted" into plain language - -**Validate:** Full user journey works with real wallet on localhost. All edge cases handled. - -## 🚨 NEVER COMMIT SECRETS TO GIT - -**Before touching Phase 2, read this.** AI agents are the #1 source of leaked credentials on GitHub. Bots scrape repos in real-time and exploit leaked secrets within seconds. - -**This means ALL secrets — not just wallet private keys:** -- **Wallet private keys** — funds drained in seconds -- **API keys** — Alchemy, Infura, Etherscan, WalletConnect project IDs -- **RPC URLs with embedded keys** — e.g. `https://base-mainnet.g.alchemy.com/v2/YOUR_KEY` -- **OAuth tokens, passwords, bearer tokens** - -**⚠️ Common SE2 Trap: `scaffold.config.ts`** - -`rpcOverrides` and `alchemyApiKey` in `scaffold.config.ts` are committed to Git. **NEVER paste API keys directly into this file.** Use environment variables: - -```typescript -// ❌ WRONG — key committed to public repo -rpcOverrides: { - [chains.base.id]: "https://base-mainnet.g.alchemy.com/v2/8GVG8WjDs-LEAKED", -}, - -// ✅ RIGHT — key stays in .env.local -rpcOverrides: { - [chains.base.id]: process.env.NEXT_PUBLIC_BASE_RPC || "https://mainnet.base.org", -}, -``` - -**Before every `git add` or `git commit`:** -```bash -# Check for leaked secrets -git diff --cached --name-only | grep -iE '\.env|key|secret|private' -grep -rn "0x[a-fA-F0-9]\{64\}" packages/ --include="*.ts" --include="*.js" --include="*.sol" -# Check for hardcoded API keys in config files -grep -rn "g.alchemy.com/v2/[A-Za-z0-9]" packages/ --include="*.ts" --include="*.js" -grep -rn "infura.io/v3/[A-Za-z0-9]" packages/ --include="*.ts" --include="*.js" -# If ANYTHING matches, STOP. Move the secret to .env and add .env to .gitignore. -``` - -**Your `.gitignore` MUST include:** -``` -.env -.env.* -*.key -broadcast/ -cache/ -node_modules/ -``` - -**SE2 handles deployer keys by default** — `yarn generate` creates a `.env` with the deployer key, and `.gitignore` excludes it. **Don't override this pattern.** Don't copy keys into scripts, config files, or deploy logs. This includes RPC keys, API keys, and any credential — not just wallet keys. - -See `wallets/SKILL.md` for full key safety guide, what to do if you've already leaked a key, and safe patterns for deployment. - -## Phase 2: Live Contracts + Local UI - -1. Update `scaffold.config.ts`: `targetNetworks: [mainnet]` (or your L2) -2. Fund deployer: `yarn generate` → `yarn account` → send real ETH -3. Deploy: `yarn deploy --network mainnet` -4. Verify: `yarn verify --network mainnet` -5. Test with real wallet, small amounts ($1-10) -6. Polish UI — remove SE2 branding, custom styling - -**Design rule:** NO LLM SLOP. No generic purple gradients. Make it unique. - -**Validate:** Contracts verified on block explorer. Full journey works with real contracts. - -## Phase 3: Production Deploy - -### Pre-deploy Checklist -- `onlyLocalBurnerWallet: true` in scaffold.config.ts (CRITICAL — prevents burner wallet on prod) -- Update metadata (title, description, OG image 1200x630px) -- Restore any test values to production values - -### Deploy - -**IPFS (decentralized):** -```bash -yarn ipfs -# → https://YOUR_CID.ipfs.cf-ipfs.com -``` - -**Vercel (fast):** -```bash -cd packages/nextjs && vercel -``` - -### Production QA -- [ ] App loads on public URL -- [ ] Wallet connects, network switching works -- [ ] Read + write contract operations work -- [ ] No console errors -- [ ] Burner wallet NOT showing -- [ ] OG image works in link previews -- [ ] Mobile responsive -- [ ] Tested with MetaMask, Rainbow, WalletConnect - -## Phase Transition Rules - -**Phase 3 bug → go back to Phase 2** (fix with local UI + prod contracts) -**Phase 2 contract bug → go back to Phase 1** (fix locally, write regression test, redeploy) -**Never hack around bugs in production.** - -## Key SE2 Directories - -``` -packages/ -├── foundry/contracts/ # Solidity contracts -├── foundry/script/ # Deploy scripts -├── foundry/test/ # Tests -└── nextjs/ - ├── app/ # Pages - ├── components/ # React components - ├── contracts/ - │ ├── deployedContracts.ts # AUTO-GENERATED (don't edit) - │ └── externalContracts.ts # YOUR external contracts (edit this) - ├── hooks/scaffold-eth/ # USE THESE hooks - └── scaffold.config.ts # Main config -``` - -## AI Agent Commerce: End-to-End Flow (ERC-8004 + x402) - -This is the killer use case for Ethereum in 2026: **autonomous agents discovering, trusting, paying, and rating each other** — no humans in the loop. - -### The Full Cycle - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 1. DISCOVER Agent queries ERC-8004 IdentityRegistry │ -│ → finds agents with "weather" service tag │ -│ │ -│ 2. TRUST Agent checks ReputationRegistry │ -│ → filters by uptime >99%, quality >85 │ -│ → picks best-rated weather agent │ -│ │ -│ 3. CALL Agent sends HTTP GET to weather endpoint │ -│ → receives 402 Payment Required │ -│ → PAYMENT-REQUIRED header: $0.10 USDC on Base │ -│ │ -│ 4. PAY Agent signs EIP-3009 transferWithAuthorization │ -│ → retries request with PAYMENT-SIGNATURE │ -│ → server verifies via facilitator │ -│ → payment settled on Base (~$0.001 gas) │ -│ │ -│ 5. RECEIVE Server returns 200 OK + weather data │ -│ → PAYMENT-RESPONSE header with tx hash │ -│ │ -│ 6. RATE Agent posts feedback to ReputationRegistry │ -│ → value=95, tag="quality", endpoint="..." │ -│ → builds onchain reputation for next caller │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Concrete Implementation (TypeScript Agent) - -```typescript -import { x402Fetch } from '@x402/fetch'; -import { createWallet } from '@x402/evm'; -import { ethers } from 'ethers'; - -const wallet = createWallet(process.env.AGENT_PRIVATE_KEY); -const provider = new ethers.JsonRpcProvider('https://base-mainnet.g.alchemy.com/v2/YOUR_KEY'); - -const IDENTITY_REGISTRY = '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432'; -const REPUTATION_REGISTRY = '0x8004BAa17C55a88189AE136b182e5fdA19dE9b63'; - -// 1. Discover: find agents offering weather service -const registry = new ethers.Contract(IDENTITY_REGISTRY, registryAbi, provider); -// Query events or use The Graph subgraph for indexed agent discovery - -// 2. Trust: check reputation -const reputation = new ethers.Contract(REPUTATION_REGISTRY, reputationAbi, provider); -const [count, value, decimals] = await reputation.getSummary( - agentId, trustedClients, "quality", "30days" -); -// Only proceed if value/10^decimals > 85 - -// 3-5. Pay + Receive: x402Fetch handles the entire 402 flow -const response = await x402Fetch(agentEndpoint, { - wallet, - preferredNetwork: 'eip155:8453' -}); -const weatherData = await response.json(); - -// 6. Rate: post feedback onchain -const reputationWriter = new ethers.Contract(REPUTATION_REGISTRY, reputationAbi, signer); -await reputationWriter.giveFeedback( - agentId, 95, 0, "quality", "weather", agentEndpoint, "", ethers.ZeroHash -); -``` - -**This is the agentic economy.** No API keys, no subscriptions, no invoicing, no trust assumptions. Just cryptographic identity, onchain reputation, and HTTP-native payments. - -### Shell-Based Agent Flow (Obol Stack) - -For agents running in the Obol Stack, the same cycle works via `identity.sh` + `rpc.sh`: - -```bash -# 1. Discover: find registered agents -sh scripts/identity.sh events registered --from-block 0 -# Parse logs for agents with matching service tags - -# 2. Trust: check reputation -sh scripts/identity.sh reputation 42 --tag1 "quality" --tag2 "30days" -# → count=47 value=92 decimals=0 (92/100 average quality) - -# 3-5. Call + Pay: x402 flow (requires TS/Python SDK — not yet shell-native) -# The x402 payment negotiation happens at the HTTP level - -# 6. Rate: post feedback onchain -sh scripts/identity.sh --from 0xYourAddress feedback 42 95 0 "quality" "weather" \ - --endpoint "https://weather.agent.example.com" -``` - -Agent identity registration tooling is coming soon. - -### Key Projects Building This Stack -- **ERC-8004** — agent identity + reputation (EF, MetaMask, Google, Coinbase) -- **x402** — HTTP payment protocol (Coinbase) -- **A2A** — agent-to-agent communication (Google) -- **MCP** — model context protocol (Anthropic) -- **The Graph** — indexing agent registrations for fast discovery -- **EigenLayer** — crypto-economic validation of agent work - -## Resources - -- **SE2 Docs:** https://docs.scaffoldeth.io/ -- **UI Components:** https://ui.scaffoldeth.io/ -- **SpeedRunEthereum:** https://speedrunethereum.com/ -- **ETH Tech Tree:** https://www.ethtechtree.com diff --git a/internal/embed/skills/qa/SKILL.md b/internal/embed/skills/qa/SKILL.md deleted file mode 100644 index 787eba26..00000000 --- a/internal/embed/skills/qa/SKILL.md +++ /dev/null @@ -1,222 +0,0 @@ ---- -name: qa -description: Pre-ship audit checklist for Ethereum dApps built with Scaffold-ETH 2. Give this to a separate reviewer agent (or fresh context) AFTER the build is complete. Covers only the bugs AI agents actually ship — validated by baseline testing against stock LLMs. ---- - -# dApp QA — Pre-Ship Audit - -This skill is for **review, not building.** Give it to a fresh agent after the dApp is built. The reviewer should: - -1. Read the source code (`app/`, `components/`, `contracts/`) -2. Open the app in a browser and click through every flow -3. Check every item below — report PASS/FAIL, don't fix - ---- - -## 🚨 Critical: Wallet Flow — Button Not Text - -Open the app with NO wallet connected. - -- ❌ **FAIL:** Text saying "Connect your wallet to play" / "Please connect to continue" / any paragraph telling the user to connect -- ✅ **PASS:** A big, obvious Connect Wallet **button** is the primary UI element - -**This is the most common AI agent mistake.** Every stock LLM writes a `

Please connect your wallet

` instead of rendering ``. - ---- - -## 🚨 Critical: Four-State Button Flow - -The app must show exactly ONE primary button at a time, progressing through: - -``` -1. Not connected → Connect Wallet button -2. Wrong network → Switch to [Chain] button -3. Needs approval → Approve button -4. Ready → Action button (Stake/Deposit/Swap) -``` - -Check specifically: -- ❌ **FAIL:** Approve and Action buttons both visible simultaneously -- ❌ **FAIL:** No network check — app tries to work on wrong chain and fails silently -- ❌ **FAIL:** User can click Approve, sign in wallet, come back, and click Approve again while tx is pending -- ✅ **PASS:** One button at a time. Approve button shows spinner, stays disabled until block confirms onchain. Then switches to the action button. - -**In the code:** the button's `disabled` prop must be tied to `isPending` from `useScaffoldWriteContract`. Verify it uses `useScaffoldWriteContract` (waits for block confirmation), NOT raw wagmi `useWriteContract` (resolves on wallet signature): - -``` -grep -rn "useWriteContract" packages/nextjs/ -``` -Any match outside scaffold-eth internals → bug. - ---- - -## 🚨 Critical: SE2 Branding Removal - -AI agents treat the scaffold as sacred and leave all default branding in place. - -- [ ] **Footer:** Remove BuidlGuidl links, "Built with 🏗️ SE2", "Fork me" link, support links. Replace with project's own repo link or clean it out -- [ ] **Tab title:** Must be the app name, NOT "Scaffold-ETH 2" or "SE-2 App" or "App Name | Scaffold-ETH 2" -- [ ] **README:** Must describe THIS project. Not the SE2 template README. Remove "Built with Scaffold-ETH 2" sections and SE2 doc links -- [ ] **Favicon:** Must not be the SE2 default - ---- - -## Important: Contract Address Display - -- ❌ **FAIL:** The deployed contract address appears nowhere on the page -- ✅ **PASS:** Contract address displayed using `
` component (blockie, ENS, copy, explorer link) - -Agents display the connected wallet address but forget to show the contract the user is interacting with. - ---- - -## Important: USD Values - -- ❌ **FAIL:** Token amounts shown as "1,000 TOKEN" or "0.5 ETH" with no dollar value -- ✅ **PASS:** "0.5 ETH (~$1,250)" with USD conversion - -Agents never add USD values unprompted. Check every place a token or ETH amount is displayed, including inputs. - ---- - -## Important: OG Image Must Be Absolute URL - -- ❌ **FAIL:** `images: ["/thumbnail.jpg"]` — relative path, breaks unfurling everywhere -- ✅ **PASS:** `images: ["https://yourdomain.com/thumbnail.jpg"]` — absolute production URL - -Quick check: -``` -grep -n "og:image\|images:" packages/nextjs/app/layout.tsx -``` - ---- - -## Important: RPC & Polling Config - -Open `packages/nextjs/scaffold.config.ts`: - -- ❌ **FAIL:** `pollingInterval: 30000` (default — makes the UI feel broken, 30 second update lag) -- ✅ **PASS:** `pollingInterval: 3000` -- ❌ **FAIL:** Using default Alchemy API key that ships with SE2 -- ❌ **FAIL:** Code references `process.env.NEXT_PUBLIC_*` but the variable isn't actually set in the deployment environment (Vercel/hosting). Falls back to public RPC like `mainnet.base.org` which is rate-limited -- ✅ **PASS:** `rpcOverrides` uses `process.env.NEXT_PUBLIC_*` variables AND the env var is confirmed set on the hosting platform - -**Verify the env var is set, not just referenced.** AI agents will change the code to use `process.env`, see the pattern matches PASS, and move on — without ever setting the actual variable on Vercel/hosting. Check: -```bash -vercel env ls | grep RPC -``` - ---- - -## Important: Phantom Wallet in RainbowKit - -Phantom is NOT in the SE2 default wallet list. A lot of users have Phantom — if it's missing, they can't connect. - -- ❌ **FAIL:** Phantom wallet not in the RainbowKit wallet list -- ✅ **PASS:** `phantomWallet` is in `wagmiConnectors.tsx` - ---- - -## Important: Mobile Deep Linking - -**RainbowKit v2 / WalletConnect v2 does NOT auto-deep-link to the wallet app.** It relies on push notifications instead, which are slow and unreliable. You must implement deep linking yourself. - -On mobile, when a user taps a button that needs a signature, it must open their wallet app. Test this: open the app on a phone, connect a wallet via WalletConnect, tap an action button — does the wallet app open with the transaction ready to sign? - -- ❌ **FAIL:** Nothing happens, user has to manually switch to their wallet app -- ❌ **FAIL:** Deep link fires BEFORE the transaction — user arrives at wallet with nothing to sign -- ❌ **FAIL:** `window.location.href = "rainbow://"` called before `writeContractAsync()` — navigates away and the TX never fires -- ❌ **FAIL:** It opens the wrong wallet (e.g. opens MetaMask when user connected with Rainbow) -- ❌ **FAIL:** Deep links inside a wallet's in-app browser (unnecessary — you're already in the wallet) -- ✅ **PASS:** Every transaction button fires the TX first, then deep links to the correct wallet app after a delay - -### How to implement it - -**Pattern: `writeAndOpen` helper.** Fire the write call first (sends the TX request over WalletConnect), then deep link after a delay to switch the user to their wallet: - -```typescript -const writeAndOpen = useCallback( - (writeFn: () => Promise): Promise => { - const promise = writeFn(); // Fire TX — does gas estimation + WC relay - setTimeout(openWallet, 2000); // Switch to wallet AFTER request is relayed - return promise; - }, - [openWallet], -); - -// Usage — wraps every write call: -await writeAndOpen(() => gameWrite({ functionName: "click", args: [...] })); -``` - -**Why 2 seconds?** `writeContractAsync` must estimate gas, encode calldata, and relay the signing request through WalletConnect's servers. 300ms is too fast — the wallet won't have received the request yet. - -**Detecting the wallet:** `connector.id` from wagmi says `"walletConnect"`, NOT `"rainbow"` or `"metamask"`. You must check multiple sources: - -```typescript -const openWallet = useCallback(() => { - if (typeof window === "undefined") return; - const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); - if (!isMobile || window.ethereum) return; // Skip if desktop or in-app browser - - // Check connector, wagmi storage, AND WalletConnect session data - const allIds = [connector?.id, connector?.name, - localStorage.getItem("wagmi.recentConnectorId")] - .filter(Boolean).join(" ").toLowerCase(); - - let wcWallet = ""; - try { - const wcKey = Object.keys(localStorage).find(k => k.startsWith("wc@2:client")); - if (wcKey) wcWallet = (localStorage.getItem(wcKey) || "").toLowerCase(); - } catch {} - const search = `${allIds} ${wcWallet}`; - - const schemes: [string[], string][] = [ - [["rainbow"], "rainbow://"], - [["metamask"], "metamask://"], - [["coinbase", "cbwallet"], "cbwallet://"], - [["trust"], "trust://"], - [["phantom"], "phantom://"], - ]; - - for (const [keywords, scheme] of schemes) { - if (keywords.some(k => search.includes(k))) { - window.location.href = scheme; - return; - } - } -}, [connector]); -``` - -**Key rules:** -1. **Fire TX first, deep link second.** Never `window.location.href` before the write call -2. **Skip deep link if `window.ethereum` exists** — means you're already in the wallet's in-app browser -3. **Check WalletConnect session data** in localStorage — `connector.id` alone won't tell you which wallet -4. **Use simple scheme URLs** like `rainbow://` — not `rainbow://dapp/...` which reloads the page -5. **Wrap EVERY write call** — approve, action, claim, batch — not just the main one - ---- - -## Audit Summary - -Report each as PASS or FAIL: - -### Ship-Blocking -- [ ] Wallet connection shows a BUTTON, not text -- [ ] Wrong network shows a Switch button -- [ ] One button at a time (Connect → Network → Approve → Action) -- [ ] Approve button disabled with spinner through block confirmation -- [ ] SE2 footer branding removed -- [ ] SE2 tab title removed -- [ ] SE2 README replaced - -### Should Fix -- [ ] Contract address displayed with `
` -- [ ] USD values next to all token/ETH amounts -- [ ] OG image is absolute production URL -- [ ] pollingInterval is 3000 -- [ ] RPC overrides set (not default SE2 key) AND env var confirmed set on hosting platform -- [ ] Favicon updated from SE2 default -- [ ] Phantom wallet in RainbowKit wallet list -- [ ] Mobile: ALL transaction buttons deep link to wallet (fire TX first, then `setTimeout(openWallet, 2000)`) -- [ ] Mobile: wallet detection checks WC session data, not just `connector.id` -- [ ] Mobile: no deep link when `window.ethereum` exists (in-app browser) diff --git a/internal/embed/skills/security/SKILL.md b/internal/embed/skills/security/SKILL.md deleted file mode 100644 index b166a1e7..00000000 --- a/internal/embed/skills/security/SKILL.md +++ /dev/null @@ -1,476 +0,0 @@ ---- -name: security -description: Solidity security patterns, common vulnerabilities, and pre-deploy audit checklist. The specific code patterns that prevent real losses — not just warnings, but defensive implementations. Use before deploying any contract, when reviewing code, or when building anything that holds or moves value. ---- - -# Smart Contract Security - -## What You Probably Got Wrong - -**"Solidity 0.8+ prevents overflows, so I'm safe."** Overflow is one of dozens of attack vectors. The big ones today: reentrancy, oracle manipulation, approval exploits, and decimal mishandling. - -**"I tested it and it works."** Working correctly is not the same as being secure. Most exploits call functions in orders or with values the developer never considered. - -**"It's a small contract, it doesn't need an audit."** The DAO hack was a simple reentrancy bug. The Euler exploit was a single missing check. Size doesn't correlate with safety. - -## Critical Vulnerabilities (With Defensive Code) - -### 1. Token Decimals Vary - -**USDC has 6 decimals, not 18.** This is the #1 source of "where did my money go?" bugs. - -```solidity -// ❌ WRONG — assumes 18 decimals. Transfers 1 TRILLION USDC. -uint256 oneToken = 1e18; - -// ✅ CORRECT — check decimals -uint256 oneToken = 10 ** IERC20Metadata(token).decimals(); -``` - -Common decimals: -| Token | Decimals | -|-------|----------| -| USDC, USDT | 6 | -| WBTC | 8 | -| DAI, WETH, most tokens | 18 | - -**When doing math across tokens with different decimals, normalize first:** -```solidity -// Converting USDC amount to 18-decimal internal accounting -uint256 normalized = usdcAmount * 1e12; // 6 + 12 = 18 decimals -``` - -### 2. No Floating Point in Solidity - -Solidity has no `float` or `double`. Division truncates to zero. - -```solidity -// ❌ WRONG — this equals 0 -uint256 fivePercent = 5 / 100; - -// ✅ CORRECT — basis points (1 bp = 0.01%) -uint256 FEE_BPS = 500; // 5% = 500 basis points -uint256 fee = (amount * FEE_BPS) / 10_000; -``` - -**Always multiply before dividing.** Division first = precision loss. - -```solidity -// ❌ WRONG — loses precision -uint256 result = a / b * c; - -// ✅ CORRECT — multiply first -uint256 result = (a * c) / b; -``` - -For complex math, use fixed-point libraries like `PRBMath` or `ABDKMath64x64`. - -### 3. Reentrancy - -An external call can call back into your contract before the first call finishes. If you update state AFTER the external call, the attacker re-enters with stale state. - -```solidity -// ❌ VULNERABLE — state updated after external call -function withdraw() external { - uint256 bal = balances[msg.sender]; - (bool success,) = msg.sender.call{value: bal}(""); // ← attacker re-enters here - require(success); - balances[msg.sender] = 0; // Too late — attacker already withdrew again -} - -// ✅ SAFE — Checks-Effects-Interactions pattern + reentrancy guard -import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; - -function withdraw() external nonReentrant { - uint256 bal = balances[msg.sender]; - require(bal > 0, "Nothing to withdraw"); - - balances[msg.sender] = 0; // Effect BEFORE interaction - - (bool success,) = msg.sender.call{value: bal}(""); - require(success, "Transfer failed"); -} -``` - -**The pattern: Checks → Effects → Interactions (CEI)** -1. **Checks** — validate inputs and conditions -2. **Effects** — update all state -3. **Interactions** — external calls last - -Always use OpenZeppelin's `ReentrancyGuard` as a safety net on top of CEI. - -### 4. SafeERC20 - -Some tokens (notably USDT) don't return `bool` on `transfer()` and `approve()`. Standard calls will revert even on success. - -```solidity -// ❌ WRONG — breaks with USDT and other non-standard tokens -token.transfer(to, amount); -token.approve(spender, amount); - -// ✅ CORRECT — handles all token implementations -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -using SafeERC20 for IERC20; - -token.safeTransfer(to, amount); -token.safeApprove(spender, amount); -``` - -**Other token quirks to watch for:** -- **Fee-on-transfer tokens:** Amount received < amount sent. Always check balance before and after. -- **Rebasing tokens (stETH):** Balance changes without transfers. Use wrapped versions (wstETH). -- **Pausable tokens (USDC):** Transfers can revert if the token is paused. -- **Blocklist tokens (USDC, USDT):** Specific addresses can be blocked from transacting. - -### 5. Never Use DEX Spot Prices as Oracles - -A flash loan can manipulate any pool's spot price within a single transaction. This has caused hundreds of millions in losses. - -```solidity -// ❌ DANGEROUS — manipulable in one transaction -function getPrice() internal view returns (uint256) { - (uint112 reserve0, uint112 reserve1,) = uniswapPair.getReserves(); - return (reserve1 * 1e18) / reserve0; // Spot price — easily manipulated -} - -// ✅ SAFE — Chainlink with staleness + sanity checks -function getPrice() internal view returns (uint256) { - (, int256 price,, uint256 updatedAt,) = priceFeed.latestRoundData(); - require(block.timestamp - updatedAt < 3600, "Stale price"); - require(price > 0, "Invalid price"); - return uint256(price); -} -``` - -**If you must use onchain price data:** -- Use **TWAP** (Time-Weighted Average Price) over 30+ minutes — resistant to single-block manipulation -- Uniswap V3 has built-in TWAP oracles via `observe()` -- Still less safe than Chainlink for high-value decisions - -### 6. Vault Inflation Attack - -The first depositor in an ERC-4626 vault can manipulate the share price to steal from subsequent depositors. - -**The attack:** -1. Attacker deposits 1 wei → gets 1 share -2. Attacker donates 1000 tokens directly to the vault (not via deposit) -3. Now 1 share = 1001 tokens -4. Victim deposits 1999 tokens → gets `1999 * 1 / 2000 = 0 shares` (rounds down) -5. Attacker redeems 1 share → gets all 3000 tokens - -**The fix — virtual offset:** -```solidity -function convertToShares(uint256 assets) public view returns (uint256) { - return assets.mulDiv( - totalSupply() + 1e3, // Virtual shares - totalAssets() + 1 // Virtual assets - ); -} -``` - -The virtual offset makes the attack uneconomical — the attacker would need to donate enormous amounts to manipulate the ratio. - -OpenZeppelin's ERC4626 implementation includes this mitigation by default since v5. - -### 7. Infinite Approvals - -**Never use `type(uint256).max` as approval amount.** - -```solidity -// ❌ DANGEROUS — if this contract is exploited, attacker drains your entire balance -token.approve(someContract, type(uint256).max); - -// ✅ SAFE — approve only what's needed -token.approve(someContract, exactAmountNeeded); - -// ✅ ACCEPTABLE — approve a small multiple for repeated interactions -token.approve(someContract, amountPerTx * 5); // 5 transactions worth -``` - -If a contract with infinite approval gets exploited (proxy upgrade bug, governance attack, undiscovered vulnerability), the attacker can drain every approved token from every user who granted unlimited access. - -### 8. Access Control - -Every state-changing function needs explicit access control. "Who should be able to call this?" is the first question. - -```solidity -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; - -// ❌ WRONG — anyone can drain the contract -function emergencyWithdraw() external { - token.transfer(msg.sender, token.balanceOf(address(this))); -} - -// ✅ CORRECT — only owner -function emergencyWithdraw() external onlyOwner { - token.transfer(owner(), token.balanceOf(address(this))); -} -``` - -For complex permissions, use OpenZeppelin's `AccessControl` with role-based separation (ADMIN_ROLE, OPERATOR_ROLE, etc.). - -### 9. Input Validation - -Never trust inputs. Validate everything. - -```solidity -function deposit(uint256 amount, address recipient) external { - require(amount > 0, "Zero amount"); - require(recipient != address(0), "Zero address"); - require(amount <= maxDeposit, "Exceeds max"); - - // Now proceed -} -``` - -Common missed validations: -- Zero addresses (tokens sent to 0x0 are burned forever) -- Zero amounts (wastes gas, can cause division by zero) -- Array length mismatches in batch operations -- Duplicate entries in arrays -- Values exceeding reasonable bounds - -## Pre-Deploy Security Checklist - -Run through this for EVERY contract before deploying to production. No exceptions. - -- [ ] **Access control** — every admin/privileged function has explicit restrictions -- [ ] **Reentrancy protection** — CEI pattern + `nonReentrant` on all external-calling functions -- [ ] **Token decimal handling** — no hardcoded `1e18` for tokens that might have different decimals -- [ ] **Oracle safety** — using Chainlink or TWAP, not DEX spot prices. Staleness checks present -- [ ] **Integer math** — multiply before divide. No precision loss in critical calculations -- [ ] **Return values checked** — using SafeERC20 for all token operations -- [ ] **Input validation** — zero address, zero amount, bounds checks on all public functions -- [ ] **Events emitted** — every state change emits an event for offchain tracking -- [ ] **Incentive design** — maintenance functions callable by anyone with sufficient incentive -- [ ] **No infinite approvals** — approve exact amounts or small bounded multiples -- [ ] **Fee-on-transfer safe** — if accepting arbitrary tokens, measure actual received amount -- [ ] **Tested edge cases** — zero values, max values, unauthorized callers, reentrancy attempts - -## MEV & Sandwich Attacks - -**MEV (Maximal Extractable Value):** Validators and searchers can reorder, insert, or censor transactions within a block. They profit by frontrunning your transaction, backrunning it, or both. - -### Sandwich Attacks - -The most common MEV attack on DeFi users: - -``` -1. You submit: swap 10 ETH → USDC on Uniswap (slippage 1%) -2. Attacker sees your tx in the mempool -3. Attacker frontruns: buys USDC before you → price rises -4. Your swap executes at a worse price (but within your 1% slippage) -5. Attacker backruns: sells USDC after you → profits from the price difference -6. You got fewer USDC than the true market price -``` - -### Protection - -```solidity -// ✅ Set explicit minimum output — don't set amountOutMinimum to 0 -ISwapRouter.ExactInputSingleParams memory params = ISwapRouter - .ExactInputSingleParams({ - tokenIn: WETH, - tokenOut: USDC, - fee: 3000, - recipient: msg.sender, - amountIn: 1 ether, - amountOutMinimum: 1900e6, // ← Minimum acceptable USDC (protects against sandwich) - sqrtPriceLimitX96: 0 - }); -``` - -**For users/frontends:** -- Use **Flashbots Protect RPC** (`https://rpc.flashbots.net`) — sends transactions to a private mempool, invisible to sandwich bots -- Set tight slippage limits (0.5-1% for majors, 1-3% for small tokens) -- Use MEV-aware DEX aggregators (CoW Swap, 1inch Fusion) that route through solvers instead of the public mempool - -**When MEV matters:** -- Any swap on a DEX (especially large swaps) -- Any large DeFi transaction (deposits, withdrawals, liquidations) -- NFT mints with high demand (bots frontrun to mint first) - -**When MEV doesn't matter:** -- Simple ETH/token transfers -- L2 transactions (sequencers process transactions in order — no public mempool reordering) -- Private mempool transactions (Flashbots, MEV Blocker) - ---- - -## Proxy Patterns & Upgradeability - -Smart contracts are immutable by default. Proxies let you upgrade the logic while keeping the same address and state. - -### When to Use Proxies - -- **Use proxies:** Long-lived protocols that may need bug fixes or feature additions post-launch -- **Don't use proxies:** MVPs, simple tokens, immutable-by-design contracts, contracts where "no one can change this" IS the value proposition - -**Proxies add complexity, attack surface, and trust assumptions.** Users must trust that the admin won't upgrade to a malicious implementation. Don't use proxies just because you can. - -### UUPS vs Transparent Proxy - -| | UUPS | Transparent | -|---|---|---| -| Upgrade logic location | In implementation contract | In proxy contract | -| Gas cost for users | Lower (no admin check per call) | Higher (checks msg.sender on every call) | -| Recommended | **Yes** (by OpenZeppelin) | Legacy pattern | -| Risk | Forgetting `_authorizeUpgrade` locks the contract | More gas overhead | - -**Use UUPS.** It's cheaper, simpler, and what OpenZeppelin recommends. - -### UUPS Implementation - -```solidity -// Implementation contract (the logic) -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; - -contract MyContractV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { - uint256 public value; - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); // Prevent implementation from being initialized - } - - function initialize(address owner) public initializer { - __Ownable_init(owner); - __UUPSUpgradeable_init(); - value = 42; - } - - function _authorizeUpgrade(address) internal override onlyOwner {} -} -``` - -### Critical Rules - -1. **Use `initializer` instead of `constructor`** — proxies don't run constructors -2. **Never change storage layout** — only append new variables at the end, never delete or reorder -3. **Use OpenZeppelin's upgradeable contracts** — `@openzeppelin/contracts-upgradeable`, not `@openzeppelin/contracts` -4. **Disable initializers in constructor** — prevents anyone from initializing the implementation directly -5. **Transfer upgrade authority to a multisig** — never leave upgrade power with a single EOA - -```solidity -// ❌ WRONG — reordering storage breaks everything -// V1: uint256 a; uint256 b; -// V2: uint256 b; uint256 a; ← Swapped! 'a' now reads 'b's value - -// ✅ CORRECT — only append -// V1: uint256 a; uint256 b; -// V2: uint256 a; uint256 b; uint256 c; ← New variable at the end -``` - ---- - -## EIP-712 Signatures & Delegatecall - -### EIP-712: Typed Structured Data Signing - -EIP-712 lets users sign structured data (not just raw bytes) with domain separation and replay protection. Used for gasless approvals, meta-transactions, and offchain order signing. - -**When to use:** -- **Permit (ERC-2612)** — gasless token approvals (user signs, anyone can submit) -- **Offchain orders** — sign buy/sell orders offchain, settle onchain (0x, Seaport) -- **Meta-transactions** — user signs intent, relayer submits and pays gas - -```solidity -// EIP-712 domain separator — prevents replay across contracts and chains -bytes32 public constant DOMAIN_TYPEHASH = keccak256( - "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" -); - -bytes32 public constant PERMIT_TYPEHASH = keccak256( - "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" -); - -function permit( - address owner, address spender, uint256 value, - uint256 deadline, uint8 v, bytes32 r, bytes32 s -) external { - require(block.timestamp <= deadline, "Permit expired"); - - bytes32 structHash = keccak256(abi.encode( - PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline - )); - bytes32 digest = keccak256(abi.encodePacked( - "\x19\x01", DOMAIN_SEPARATOR(), structHash - )); - - address recovered = ecrecover(digest, v, r, s); - require(recovered == owner, "Invalid signature"); - - _approve(owner, spender, value); -} -``` - -**Key properties:** -- **Domain separator** prevents replaying signatures on different contracts or chains -- **Nonce** prevents replaying the same signature twice -- **Deadline** prevents stale signatures from being used later -- In practice, use OpenZeppelin's `EIP712` and `ERC20Permit` — don't implement from scratch - -### Delegatecall - -`delegatecall` executes another contract's code in the caller's storage context. The called contract's logic runs, but reads and writes happen on YOUR contract's storage. - -**This is extremely dangerous if the target is untrusted.** - -```solidity -// ❌ CRITICAL VULNERABILITY — delegatecall to user-supplied address -function execute(address target, bytes calldata data) external { - target.delegatecall(data); // Attacker can overwrite ANY storage slot -} - -// ✅ SAFE — delegatecall only to trusted, immutable implementation -address public immutable trustedImplementation; - -function execute(bytes calldata data) external onlyOwner { - trustedImplementation.delegatecall(data); -} -``` - -**Delegatecall rules:** -- **Never delegatecall to a user-supplied address** — allows arbitrary storage manipulation -- **Only delegatecall to contracts YOU control** — and preferably immutable ones -- **Storage layouts must match** — the calling contract and target contract must have identical storage variable ordering -- **This is how proxies work** — the proxy delegatecalls to the implementation, so the implementation's code runs on the proxy's storage. That's why storage layout matters so much for upgradeable contracts. - ---- - -## Automated Security Tools - -Run these before deployment: - -```bash -# Static analysis -slither . # Detects common vulnerabilities -mythril analyze Contract.sol # Symbolic execution - -# Foundry fuzzing (built-in) -forge test --fuzz-runs 10000 # Fuzz all test functions with random inputs - -# Gas optimization (bonus) -forge test --gas-report # Identify expensive functions -``` - -**Slither findings to NEVER ignore:** -- Reentrancy vulnerabilities -- Unchecked return values -- Arbitrary `delegatecall` or `selfdestruct` -- Unprotected state-changing functions - -## See Also - -- `testing` — fuzz testing, invariant testing, fork testing patterns -- `wallets` — key safety guardrails for AI agents -- `addresses` — verified contract addresses (avoid using wrong addresses) - -## Further Reading - -- **OpenZeppelin Contracts:** https://docs.openzeppelin.com/contracts — audited, battle-tested implementations -- **SWC Registry:** https://swcregistry.io — comprehensive vulnerability catalog -- **Rekt News:** https://rekt.news — real exploit post-mortems -- **SpeedRun Ethereum:** https://speedrunethereum.com — hands-on secure development practice diff --git a/internal/embed/skills/ship/SKILL.md b/internal/embed/skills/ship/SKILL.md deleted file mode 100644 index d1fd9f9b..00000000 --- a/internal/embed/skills/ship/SKILL.md +++ /dev/null @@ -1,323 +0,0 @@ ---- -name: ship -description: End-to-end guide for AI agents — from a dApp idea to deployed production app. Fetch this FIRST, it routes you through all other skills. ---- - -# Ship a dApp - -## What You Probably Got Wrong - -**You jump to code without a plan.** Before writing a single line of Solidity, you need to know: what goes onchain, what stays offchain, which chain, how many contracts, and who calls every function. Skip this and you'll rewrite everything. - -**You over-engineer.** Most dApps need 0-2 contracts. A token launch is 1 contract. An NFT collection is 1 contract. A marketplace that uses existing DEX liquidity needs 0 contracts. Three contracts is the upper bound for an MVP. If you're writing more, you're building too much. - -**You put too much onchain.** Solidity is for ownership, transfers, and commitments. It's not a database. It's not an API. It's not a backend. If it doesn't involve trustless value transfer or a permanent commitment, it doesn't belong in a smart contract. - -**You skip chain selection.** The 2026 answer is almost always an L2. Base for consumer apps. Arbitrum for DeFi. Optimism for public goods. Mainnet only if you need maximum security or composability with mainnet-only protocols. Fetch `l2s/SKILL.md` for the full decision matrix. - -**You forget nothing is automatic.** Smart contracts don't run themselves. Every state transition needs a caller who pays gas and a reason to do it. If you can't answer "who calls this and why?" for every function, your contract has dead code. Fetch `concepts/SKILL.md` for the full mental model. - ---- - -## Phase 0 — Plan the Architecture - -Do this BEFORE writing any code. Every hour spent here saves ten hours of rewrites. - -### The Onchain Litmus Test - -Put it onchain if it involves: -- **Trustless ownership** — who owns this token/NFT/position? -- **Trustless exchange** — swapping, trading, lending, borrowing -- **Composability** — other contracts need to call it -- **Censorship resistance** — must work even if your team disappears -- **Permanent commitments** — votes, attestations, proofs - -Keep it offchain if it involves: -- User profiles, preferences, settings -- Search, filtering, sorting -- Images, videos, metadata (store on IPFS, reference onchain) -- Business logic that changes frequently -- Anything that doesn't involve value transfer or trust - -**Judgment calls:** -- Reputation scores → offchain compute, onchain commitments (hashes or attestations) -- Activity feeds → offchain indexing of onchain events (fetch `indexing/SKILL.md`) -- Price data → offchain oracles writing onchain (Chainlink) -- Game state → depends on stakes. Poker with real money? Onchain. Leaderboard? Offchain. - -### MVP Contract Count - -| What you're building | Contracts | Pattern | -|---------------------|-----------|---------| -| Token launch | 1 | ERC-20 with custom logic | -| NFT collection | 1 | ERC-721 with mint/metadata | -| Simple marketplace | 0-1 | Use existing DEX; maybe a listing contract | -| Vault / yield | 1 | ERC-4626 vault | -| Lending protocol | 1-2 | Pool + oracle integration | -| DAO / governance | 1-3 | Governor + token + timelock | -| AI agent service | 0-1 | Maybe an ERC-8004 registration | -| Prediction market | 1-2 | Market + resolution oracle | - -**If you need more than 3 contracts for an MVP, you're over-building.** Ship the simplest version that works, then iterate. - -### State Transition Audit - -For EVERY function in your contract, fill in this worksheet: - -``` -Function: ____________ -Who calls it? ____________ -Why would they? ____________ -What if nobody calls it? ____________ -Does it need gas incentives? ____________ -``` - -If "what if nobody calls it?" breaks your system, you have a design problem. Fix it before writing code. See `concepts/SKILL.md` for incentive design patterns. - -### Chain Selection (Quick Version) - -| Priority | Chain | Why | -|----------|-------|-----| -| Consumer app, low fees | **Base** | Cheapest L2, Coinbase distribution, strong ecosystem | -| DeFi, complex protocols | **Arbitrum** | Deepest DeFi liquidity on any L2, mature tooling | -| Public goods, governance | **Optimism** | Retroactive public goods funding, OP Stack ecosystem | -| Maximum security | **Ethereum mainnet** | Only if you need mainnet composability or $100M+ TVL | -| Privacy features | **zkSync / Scroll** | ZK rollups with potential privacy extensions | - -Fetch `l2s/SKILL.md` for the complete comparison with gas costs, bridging, and deployment differences. - ---- - -## dApp Archetype Templates - -Find your archetype below. Each tells you exactly how many contracts you need, what they do, common mistakes, and which skills to fetch. - -### 1. Token Launch (1-2 contracts) - -**Architecture:** One ERC-20 contract. Add a vesting contract if you have team/investor allocations. - -**Contracts:** -- `MyToken.sol` — ERC-20 with initial supply, maybe mint/burn -- `TokenVesting.sol` (optional) — time-locked releases for team tokens - -**Common mistakes:** -- Infinite supply with no burn mechanism (what gives it value?) -- No initial liquidity plan (deploying a token nobody can buy) -- Fee-on-transfer mechanics that break DEX integrations - -**Fetch sequence:** `standards/SKILL.md` → `security/SKILL.md` → `testing/SKILL.md` → `gas/SKILL.md` - -### 2. NFT Collection (1 contract) - -**Architecture:** One ERC-721 contract. Metadata on IPFS. Frontend for minting. - -**Contracts:** -- `MyNFT.sol` — ERC-721 with mint, max supply, metadata URI - -**Common mistakes:** -- Storing images onchain (use IPFS or Arweave, store the hash onchain) -- No max supply cap (unlimited minting destroys value) -- Complex whitelist logic when a simple Merkle root works - -**Fetch sequence:** `standards/SKILL.md` → `security/SKILL.md` → `testing/SKILL.md` → `frontend-ux/SKILL.md` - -### 3. Marketplace / Exchange (0-2 contracts) - -**Architecture:** If trading existing tokens, you likely need 0 contracts — integrate with Uniswap/Aerodrome. If building custom order matching, 1-2 contracts. - -**Contracts:** -- (often none — use existing DEX liquidity via router) -- `OrderBook.sol` (if custom) — listing, matching, settlement -- `Escrow.sol` (if needed) — holds assets during trades - -**Common mistakes:** -- Building a DEX from scratch when Uniswap V4 hooks can do it -- Ignoring MEV (fetch `security/SKILL.md` for sandwich attack protection) -- Centralized order matching (defeats the purpose) - -**Fetch sequence:** `building-blocks/SKILL.md` → `addresses/SKILL.md` → `security/SKILL.md` → `testing/SKILL.md` - -### 4. Lending / Vault / Yield (0-1 contracts) - -**Architecture:** If using existing protocol (Aave, Compound), 0 contracts — just integrate. If building a vault, 1 ERC-4626 contract. - -**Contracts:** -- `MyVault.sol` — ERC-4626 vault wrapping a yield source - -**Common mistakes:** -- Ignoring vault inflation attack (fetch `security/SKILL.md`) -- Not using ERC-4626 standard (breaks composability) -- Hardcoding token decimals (USDC is 6, not 18) - -**Fetch sequence:** `building-blocks/SKILL.md` → `standards/SKILL.md` → `security/SKILL.md` → `testing/SKILL.md` - -### 5. DAO / Governance (1-3 contracts) - -**Architecture:** Governor contract + governance token + timelock. Use OpenZeppelin's Governor — don't build from scratch. - -**Contracts:** -- `GovernanceToken.sol` — ERC-20Votes -- `MyGovernor.sol` — OpenZeppelin Governor with voting parameters -- `TimelockController.sol` — delays execution for safety - -**Common mistakes:** -- No timelock (governance decisions execute instantly = rug vector) -- Low quorum that allows minority takeover -- Token distribution so concentrated that one whale controls everything - -**Fetch sequence:** `standards/SKILL.md` → `building-blocks/SKILL.md` → `security/SKILL.md` → `testing/SKILL.md` - -### 6. AI Agent Service (0-1 contracts) - -**Architecture:** Agent logic is offchain. Onchain component is optional — ERC-8004 identity registration, or a payment contract for x402. You don't deploy a contract — you register with the existing IdentityRegistry. - -**Contracts:** -- (usually none — register with the existing ERC-8004 IdentityRegistry instead of deploying) -- Custom contract only if you need bespoke onchain logic beyond identity + reputation - -**When to register with ERC-8004:** -- Your agent offers a public service other agents can discover -- You want onchain reputation that builds over time -- You accept x402 micropayments and want trust signals for clients -- You need cross-chain discoverability (same identity on 20+ chains) - -**When NOT to register:** -- Internal agent with no public-facing service -- Testing/development — register on a testnet first -- Agent doesn't interact with other agents - -**Registration cost:** Just gas (no protocol fee). Base is cheapest (~$0.01). - -**Recommended chain:** Base — cheapest gas, largest ERC-8004 ecosystem, Coinbase distribution. - -**Quick start:** -```bash -# Prepare + pin + register in the Obol Stack -sh scripts/identity.sh --network base --from 0xYourAddress pin-registration \ - --name "MyAgent" --description "What it does" \ - --services '[{"name":"A2A","endpoint":"https://your.agent/.well-known/agent-card.json","version":"0.3.0"}]' \ - --x402 -``` - -**Common mistakes:** -- Putting agent logic onchain (Solidity is not for AI inference) -- Deploying a custom AgentRegistry when the standard IdentityRegistry exists -- Overcomplicating payments (x402 handles HTTP-native payments) -- Ignoring key management (fetch `wallets/SKILL.md`) -- Registering on mainnet before testing on a testnet - -**Fetch sequence:** `standards/SKILL.md` → `wallets/SKILL.md` → `orchestration/SKILL.md` - ---- - -## Phase 1 — Build Contracts - -**Fetch:** `standards/SKILL.md`, `building-blocks/SKILL.md`, `addresses/SKILL.md`, `security/SKILL.md` - -Key guidance: -- Use OpenZeppelin contracts as your base — don't reinvent ERC-20, ERC-721, or AccessControl -- Use verified addresses from `addresses/SKILL.md` for any protocol integration — never fabricate addresses -- Follow the Checks-Effects-Interactions pattern for every external call -- Emit events for every state change (your frontend and indexer need them) -- Use `SafeERC20` for all token operations -- Run through the security checklist in `security/SKILL.md` before moving to Phase 2 - -For SE2 projects, follow `orchestration/SKILL.md` Phase 1 for the exact build sequence. - ---- - -## Phase 2 — Test - -**Fetch:** `testing/SKILL.md` - -Don't skip this. Don't "test later." Test before deploy. - -Key guidance: -- Unit test every custom function (not OpenZeppelin internals) -- Fuzz test all math operations — fuzzing finds the bugs you didn't think of -- Fork test any integration with external protocols (Uniswap, Aave, etc.) -- Run `slither .` for static analysis before deploying -- Target edge cases: zero amounts, max uint, empty arrays, self-transfers, unauthorized callers - ---- - -## Phase 3 — Build Frontend - -**Fetch:** `orchestration/SKILL.md`, `frontend-ux/SKILL.md`, `tools/SKILL.md` - -Key guidance: -- Use Scaffold-ETH 2 hooks, not raw wagmi — `useScaffoldReadContract`, `useScaffoldWriteContract` -- Implement the three-button flow: Switch Network → Approve → Execute -- Show loading states on every async operation (blockchains take 5-12 seconds) -- Display token amounts in human-readable form with `formatEther`/`formatUnits` -- Never use infinite approvals - ---- - -## Phase 4 — Ship to Production - -**Fetch:** `wallets/SKILL.md`, `frontend-playbook/SKILL.md`, `gas/SKILL.md` - -### Contract Deployment -1. Set gas settings appropriate for the target chain (fetch `gas/SKILL.md`) -2. Deploy and verify contracts on block explorer -3. Transfer ownership to a multisig (Gnosis Safe) — never leave a single EOA as owner in production -4. Post-deploy checks: call every read function, verify state, test one small transaction - -### Frontend Deployment -Fetch `frontend-playbook/SKILL.md` for the full pipeline: -- **IPFS** — decentralized, censorship-resistant, permanent -- **Vercel** — fast, easy, but centralized -- **ENS subdomain** — human-readable URL pointing to IPFS - -### Post-Launch -- Set up event monitoring with The Graph or Dune (fetch `indexing/SKILL.md`) -- Monitor contract activity on block explorer -- Have an incident response plan (pause mechanism if applicable, communication channel) - ---- - -## Anti-Patterns - -**Kitchen sink contract.** One contract doing everything — swap, lend, stake, govern. Split responsibilities. Each contract should do one thing well. - -**Factory nobody asked for.** Building a factory contract that deploys new contracts when you only need one instance. Factories are for protocols that serve many users creating their own instances (like Uniswap creating pools). Most dApps don't need them. - -**Onchain everything.** Storing user profiles, activity logs, images, or computed analytics in a smart contract. Use onchain for ownership and value transfer, offchain for everything else. - -**Admin crutch.** Relying on an admin account to call maintenance functions. What happens when the admin loses their key? Design permissionless alternatives with proper incentives. - -**Premature multi-chain.** Deploying to 5 chains on day one. Launch on one chain, prove product-market fit, then expand. Multi-chain adds complexity in bridging, state sync, and liquidity fragmentation. - -**Reinventing audited primitives.** Writing your own ERC-20, your own access control, your own math library. Use OpenZeppelin. They're audited, battle-tested, and free. Your custom version has bugs. - -**Ignoring the frontend.** A working contract with a broken UI is useless. Most users interact through the frontend, not Etherscan. Budget 40% of your time for frontend polish. - ---- - -## Quick-Start Checklist - -- [ ] Identify what goes onchain vs offchain (use the Litmus Test above) -- [ ] Count your contracts (aim for 1-2 for MVP) -- [ ] Pick your chain (Base, Arbitrum, or Optimism for most apps) -- [ ] Audit every state transition (who calls it? why?) -- [ ] Write contracts using OpenZeppelin base contracts -- [ ] Test with Foundry (unit + fuzz + fork tests) -- [ ] Deploy, verify, transfer ownership to multisig -- [ ] Ship frontend (IPFS or Vercel), run production QA - ---- - -## Skill Routing Table - -Use this to know which skills to fetch at each phase: - -| Phase | What you're doing | Skills to fetch | -|-------|-------------------|-----------------| -| **Plan** | Architecture, chain selection | `ship/` (this), `concepts/`, `l2s/`, `gas/` | -| **Contracts** | Writing Solidity | `standards/`, `building-blocks/`, `addresses/`, `security/` | -| **Test** | Testing contracts | `testing/` | -| **Frontend** | Building UI | `orchestration/`, `frontend-ux/`, `tools/` | -| **Production** | Deploy + monitor | `wallets/`, `frontend-playbook/`, `indexing/` | - -**Base URLs:** All skills are at `https://ethskills.com//SKILL.md` diff --git a/internal/embed/skills/testing/SKILL.md b/internal/embed/skills/testing/SKILL.md deleted file mode 100644 index 73a711df..00000000 --- a/internal/embed/skills/testing/SKILL.md +++ /dev/null @@ -1,390 +0,0 @@ ---- -name: testing -description: Smart contract testing with Foundry — unit tests, fuzz testing, fork testing, invariant testing. What to test, what not to test, and what LLMs get wrong. ---- - -# Smart Contract Testing - -## What You Probably Got Wrong - -**You test getters and trivial functions.** Testing that `name()` returns the name is worthless. Test edge cases, failure modes, and economic invariants — the things that lose money when they break. - -**You don't fuzz.** `forge test` finds the bugs you thought of. Fuzzing finds the ones you didn't. If your contract does math, fuzz it. If it handles user input, fuzz it. If it moves value, definitely fuzz it. - -**You don't fork-test.** If your contract calls Uniswap, Aave, or any external protocol, test against their real deployed contracts on a fork. Mocking them hides integration bugs that only appear with real state. - -**You write tests that mirror the implementation.** Testing that `deposit(100)` sets `balance[user] = 100` is tautological — you're testing that Solidity assignments work. Test properties: "after deposit and withdraw, user gets their tokens back." Test invariants: "total deposits always equals contract balance." - -**You skip invariant testing for stateful protocols.** If your contract has multiple interacting functions that change state over time (vaults, AMMs, lending), you need invariant tests. Unit tests check one path; invariant tests check that properties hold across thousands of random sequences. - ---- - -## Unit Testing with Foundry - -### Test File Structure - -```solidity -// test/MyContract.t.sol -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test, console} from "forge-std/Test.sol"; -import {MyToken} from "../src/MyToken.sol"; - -contract MyTokenTest is Test { - MyToken public token; - address public alice = makeAddr("alice"); - address public bob = makeAddr("bob"); - - function setUp() public { - token = new MyToken("Test", "TST", 1_000_000e18); - // Give alice some tokens for testing - token.transfer(alice, 10_000e18); - } - - function test_TransferUpdatesBalances() public { - vm.prank(alice); - token.transfer(bob, 1_000e18); - - assertEq(token.balanceOf(alice), 9_000e18); - assertEq(token.balanceOf(bob), 1_000e18); - } - - function test_TransferEmitsEvent() public { - vm.expectEmit(true, true, false, true); - emit Transfer(alice, bob, 500e18); - - vm.prank(alice); - token.transfer(bob, 500e18); - } - - function test_RevertWhen_TransferExceedsBalance() public { - vm.prank(alice); - vm.expectRevert(); - token.transfer(bob, 999_999e18); // More than alice has - } - - function test_RevertWhen_TransferToZeroAddress() public { - vm.prank(alice); - vm.expectRevert(); - token.transfer(address(0), 100e18); - } -} -``` - -### Key Assertion Patterns - -```solidity -// Equality -assertEq(actual, expected); -assertEq(actual, expected, "descriptive error message"); - -// Comparisons -assertGt(a, b); // a > b -assertGe(a, b); // a >= b -assertLt(a, b); // a < b -assertLe(a, b); // a <= b - -// Approximate equality (for math with rounding) -assertApproxEqAbs(actual, expected, maxDelta); -assertApproxEqRel(actual, expected, maxPercentDelta); // in WAD (1e18 = 100%) - -// Revert expectations -vm.expectRevert(); // Any revert -vm.expectRevert("Insufficient balance"); // Specific message -vm.expectRevert(MyContract.CustomError.selector); // Custom error - -// Event expectations -vm.expectEmit(true, true, false, true); // (topic1, topic2, topic3, data) -emit MyEvent(expectedArg1, expectedArg2); -``` - -### What to Actually Test - -```solidity -// ✅ TEST: Edge cases that lose money -function test_TransferZeroAmount() public { /* ... */ } -function test_TransferEntireBalance() public { /* ... */ } -function test_TransferToSelf() public { /* ... */ } -function test_ApproveOverwrite() public { /* ... */ } -function test_TransferFromWithExactAllowance() public { /* ... */ } - -// ✅ TEST: Access control -function test_RevertWhen_NonOwnerCallsAdminFunction() public { /* ... */ } -function test_OwnerCanPause() public { /* ... */ } - -// ✅ TEST: Failure modes -function test_RevertWhen_DepositZero() public { /* ... */ } -function test_RevertWhen_WithdrawMoreThanDeposited() public { /* ... */ } -function test_RevertWhen_ContractPaused() public { /* ... */ } - -// ❌ DON'T TEST: OpenZeppelin internals -// function test_NameReturnsName() — they already tested this -// function test_SymbolReturnsSymbol() — waste of time -// function test_DecimalsReturns18() — it does, trust it -``` - ---- - -## Fuzz Testing - -Foundry automatically fuzzes any test function with parameters. Instead of testing one value, it tests hundreds of random values. - -### Basic Fuzz Test - -```solidity -// Foundry calls this with random amounts -function testFuzz_DepositWithdrawRoundtrip(uint256 amount) public { - // Bound input to valid range - amount = bound(amount, 1, token.balanceOf(alice)); - - uint256 balanceBefore = token.balanceOf(alice); - - vm.startPrank(alice); - token.approve(address(vault), amount); - vault.deposit(amount, alice); - vault.withdraw(vault.balanceOf(alice), alice, alice); - vm.stopPrank(); - - // Property: user gets back what they deposited (minus any fees) - assertGe(token.balanceOf(alice), balanceBefore - 1); // Allow 1 wei rounding -} -``` - -### Bounding Inputs - -```solidity -// bound() is preferred over vm.assume() — bound reshapes, assume discards -function testFuzz_Fee(uint256 amount, uint256 feeBps) public { - amount = bound(amount, 1e6, 1e30); // Reasonable token amounts - feeBps = bound(feeBps, 1, 10_000); // 0.01% to 100% - - uint256 fee = (amount * feeBps) / 10_000; - uint256 afterFee = amount - fee; - - // Property: fee + remainder always equals original - assertEq(fee + afterFee, amount); -} - -// vm.assume() discards inputs — use sparingly -function testFuzz_Division(uint256 a, uint256 b) public { - vm.assume(b > 0); // Skip zero (would revert) - // ... -} -``` - -### Run with More Iterations - -```bash -# Default: 256 runs -forge test - -# More thorough: 10,000 runs -forge test --fuzz-runs 10000 - -# Set in foundry.toml for CI -# [fuzz] -# runs = 1000 -``` - ---- - -## Fork Testing - -Test your contract against real deployed protocols on a mainnet fork. This catches integration bugs that mocks can't. - -### Basic Fork Test - -```solidity -contract SwapTest is Test { - // Real mainnet addresses - address constant UNISWAP_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45; - address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; - address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - - function setUp() public { - // Fork mainnet at a specific block for reproducibility - vm.createSelectFork("mainnet", 19_000_000); - } - - function test_SwapETHForUSDC() public { - address user = makeAddr("user"); - vm.deal(user, 1 ether); - - vm.startPrank(user); - - // Build swap path - ISwapRouter.ExactInputSingleParams memory params = ISwapRouter - .ExactInputSingleParams({ - tokenIn: WETH, - tokenOut: USDC, - fee: 3000, - recipient: user, - amountIn: 0.1 ether, - amountOutMinimum: 0, // In production, NEVER set to 0 - sqrtPriceLimitX96: 0 - }); - - // Execute swap - uint256 amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle{value: 0.1 ether}(params); - - vm.stopPrank(); - - // Verify we got USDC back - assertGt(amountOut, 0, "Should receive USDC"); - assertGt(IERC20(USDC).balanceOf(user), 0); - } -} -``` - -### When to Fork-Test - -- **Always:** Any contract that calls an external protocol (Uniswap, Aave, Chainlink) -- **Always:** Any contract that handles tokens with quirks (USDT, fee-on-transfer, rebasing) -- **Always:** Any contract that reads oracle prices -- **Never:** Pure logic contracts with no external calls — use unit tests - -### Running Fork Tests - -```bash -# Fork from local eRPC (if running in Obol Stack with mainnet installed) -forge test --fork-url http://erpc.erpc.svc.cluster.local/rpc/mainnet - -# Fork at specific block (reproducible) -forge test --fork-url http://erpc.erpc.svc.cluster.local/rpc/mainnet --fork-block-number 19000000 - -# Set in foundry.toml to avoid CLI flags -# [rpc_endpoints] -# mainnet = "${MAINNET_RPC_URL}" -``` - ---- - -## Invariant Testing - -Invariant tests verify that properties hold across thousands of random function call sequences. Essential for stateful protocols. - -### What Are Invariants? - -Invariants are properties that must ALWAYS be true, no matter what sequence of actions users take: - -- "Total supply equals sum of all balances" (ERC-20) -- "Total deposits equals total shares times share price" (vault) -- "x * y >= k after every swap" (AMM) -- "User can always withdraw what they deposited" (escrow) - -### Basic Invariant Test - -```solidity -contract VaultInvariantTest is Test { - MyVault public vault; - IERC20 public token; - VaultHandler public handler; - - function setUp() public { - token = new MockERC20("Test", "TST", 18); - vault = new MyVault(token); - handler = new VaultHandler(vault, token); - - // Tell Foundry which contract to call randomly - targetContract(address(handler)); - } - - // This runs after every random sequence - function invariant_TotalAssetsMatchesBalance() public view { - assertEq( - vault.totalAssets(), - token.balanceOf(address(vault)), - "Total assets must equal actual balance" - ); - } - - function invariant_SharePriceNeverZero() public view { - if (vault.totalSupply() > 0) { - assertGt(vault.convertToAssets(1e18), 0, "Share price must never be zero"); - } - } -} - -// Handler: guided random actions -contract VaultHandler is Test { - MyVault public vault; - IERC20 public token; - - constructor(MyVault _vault, IERC20 _token) { - vault = _vault; - token = _token; - } - - function deposit(uint256 amount) public { - amount = bound(amount, 1, 1e24); - deal(address(token), msg.sender, amount); - - vm.startPrank(msg.sender); - token.approve(address(vault), amount); - vault.deposit(amount, msg.sender); - vm.stopPrank(); - } - - function withdraw(uint256 shares) public { - uint256 maxShares = vault.balanceOf(msg.sender); - if (maxShares == 0) return; - shares = bound(shares, 1, maxShares); - - vm.prank(msg.sender); - vault.redeem(shares, msg.sender, msg.sender); - } -} -``` - -### Running Invariant Tests - -```bash -# Default depth (15 calls per sequence, 256 sequences) -forge test - -# Deeper exploration -forge test --fuzz-runs 1000 - -# Configure in foundry.toml -# [invariant] -# runs = 512 -# depth = 50 -``` - ---- - -## What NOT to Test - -- **OpenZeppelin internals.** Don't test that `ERC20.transfer` works. It's been audited by dozens of firms and used by thousands of contracts. Test YOUR logic on top of it. -- **Solidity language features.** Don't test that `require` reverts or that `mapping` stores values. The compiler works. -- **Every getter.** If `name()` returns the name you passed to the constructor, that's not a test — it's a tautology. -- **Happy path only.** The happy path probably works. Test the unhappy paths: what happens with zero? Max uint? Unauthorized callers? Reentrancy? - -**Focus your testing effort on:** Custom business logic, mathematical operations, integration points with external protocols, access control boundaries, and economic edge cases. - ---- - -## Pre-Deploy Test Checklist - -- [ ] All custom logic has unit tests with edge cases -- [ ] Zero amounts, max uint, empty arrays, self-transfers tested -- [ ] Access control verified — unauthorized calls revert -- [ ] Fuzz tests on all mathematical operations (minimum 1000 runs) -- [ ] Fork tests for every external protocol integration -- [ ] Invariant tests for stateful protocols (vaults, AMMs, lending) -- [ ] Events verified with `expectEmit` -- [ ] Gas snapshots taken with `forge snapshot` to catch regressions -- [ ] Static analysis with `slither .` — no high/medium findings unaddressed -- [ ] All tests pass: `forge test -vvv` - -## Note on Tooling - -`forge`, `cast`, and `anvil` are pre-installed in the OpenClaw image. All commands in this skill can be run directly. - -## See Also - -- `security` — vulnerability patterns and defensive code -- `tools` — full Foundry command reference and dev environment setup -- `gas` — current gas costs and estimation commands -- `ethereum-networks` — live RPC queries for fork testing targets diff --git a/internal/embed/skills/tools/SKILL.md b/internal/embed/skills/tools/SKILL.md deleted file mode 100644 index 8e501cec..00000000 --- a/internal/embed/skills/tools/SKILL.md +++ /dev/null @@ -1,187 +0,0 @@ ---- -name: tools -description: Current Ethereum development tools, frameworks, libraries, RPCs, and block explorers. What actually works today for building on Ethereum. Includes tool discovery for AI agents — MCPs, abi.ninja, Foundry, Scaffold-ETH 2, Hardhat, and more. Use when setting up a dev environment, choosing tools, or when an agent needs to discover what's available. ---- - -# Ethereum Development Tools - -## What You Probably Got Wrong - -**Blockscout MCP server exists:** https://mcp.blockscout.com/mcp — gives AI agents structured blockchain data via Model Context Protocol. This is cutting-edge infra as of Feb 2026. - -**abi.ninja is essential:** https://abi.ninja — paste any verified contract address, get a UI to call any function. Zero setup. Supports mainnet + all major L2s. Perfect for agent-driven contract exploration. - -**x402 has production SDKs:** `@x402/fetch` (TS), `x402` (Python), `github.com/coinbase/x402/go` — production-ready libraries for HTTP payments. - -**Foundry is the default for new projects in 2026.** Not Hardhat. 10-100x faster tests, Solidity-native testing, built-in fuzzing. - -## Tool Discovery Pattern for AI Agents - -When an agent needs to interact with Ethereum: - -1. **Read operations:** Blockscout MCP or Etherscan API -2. **Write operations:** Foundry `cast send` or ethers.js/viem -3. **Contract exploration:** abi.ninja (browser) or `cast interface` (CLI) -4. **Testing:** Fork mainnet with `anvil`, test locally -5. **Deployment:** `forge create` or `forge script` -6. **Verification:** `forge verify-contract` or Etherscan API - -## Blockscout MCP Server - -**URL:** https://mcp.blockscout.com/mcp - -A Model Context Protocol server giving AI agents structured blockchain data: -- Transaction, address, contract queries -- Token info and balances -- Smart contract interaction helpers -- Multi-chain support -- Standardized interface optimized for LLM consumption - -**Why this matters:** Instead of scraping Etherscan or making raw API calls, agents get structured, type-safe blockchain data via MCP. - -## abi.ninja - -**URL:** https://abi.ninja — Paste any contract address → interact with all functions. Multi-chain. Zero setup. - -## x402 SDKs (HTTP Payments) - -**TypeScript:** -```bash -npm install @x402/core @x402/evm @x402/fetch @x402/express -``` - -```typescript -import { x402Fetch } from '@x402/fetch'; -import { createWallet } from '@x402/evm'; - -const wallet = createWallet(privateKey); -const response = await x402Fetch('https://api.example.com/data', { - wallet, - preferredNetwork: 'eip155:8453' // Base -}); -``` - -**Python:** `pip install x402` -**Go:** `go get github.com/coinbase/x402/go` -**Docs:** https://www.x402.org | https://github.com/coinbase/x402 - -## Scaffold-ETH 2 - -- **Setup:** `npx create-eth@latest` -- **What:** Full-stack Ethereum toolkit: Solidity + Next.js + Foundry -- **Key feature:** Auto-generates TypeScript types from contracts. Scaffold hooks make contract interaction trivial. -- **Deploy to IPFS:** `yarn ipfs` (BuidlGuidl IPFS) -- **UI Components:** https://ui.scaffoldeth.io/ -- **Docs:** https://docs.scaffoldeth.io/ - -## Choosing Your Stack (2026) - -| Need | Tool | -|------|------| -| Rapid prototyping / full dApps | **Scaffold-ETH 2** | -| Contract-focused dev | **Foundry** (forge + cast + anvil) | -| Quick contract interaction | **abi.ninja** (browser) or **cast** (CLI) | -| React frontends | **wagmi + viem** (or SE2 which wraps these) | -| Agent blockchain reads | **Blockscout MCP** | -| Agent payments | **x402 SDKs** | - -## Essential Foundry cast Commands - -`cast` is pre-installed in the OpenClaw image. The local eRPC gateway is the default RPC: - -```bash -RPC="http://erpc.erpc.svc.cluster.local/rpc/mainnet" - -# Read contract (with ABI decoding) -cast call 0xAddr "balanceOf(address)(uint256)" 0xWallet --rpc-url $RPC - -# Send transaction (signing wallet support coming soon) - -# Gas price / base fee -cast gas-price --rpc-url $RPC -cast base-fee --rpc-url $RPC - -# Decode calldata -cast 4byte-decode 0xa9059cbb... - -# ENS resolution -cast resolve-name vitalik.eth --rpc-url $RPC - -# Encode calldata for contract interaction -cast calldata "transfer(address,uint256)" 0xRecipient 1000000 - -# Unit conversion -cast to-wei 1.5 ether # → 1500000000000000000 -cast from-wei 1000000 gwei # → 0.001 - -# Fetch contract interface -cast interface 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --rpc-url $RPC - -# Fork mainnet locally -anvil --fork-url $RPC -``` - -For a full cast-based query tool, see the `ethereum-networks` skill (`scripts/rpc.sh`). - -## RPC Providers - -**Obol Stack (local, preferred):** -- `http://erpc.erpc.svc.cluster.local/rpc/mainnet` — local eRPC gateway, routes to installed networks -- Supports `/rpc/{network}` (mainnet, hoodi, sepolia) and `/rpc/evm/{chainId}` routing - -**Free (testing/fallback):** -- `https://eth.llamarpc.com` — LlamaNodes, no key -- `https://rpc.ankr.com/eth` — Ankr, free tier - -**Paid (production):** -- **Alchemy** — most popular, generous free tier (300M CU/month) -- **Infura** — established, MetaMask default -- **QuickNode** — performance-focused - -**Community:** `rpc.buidlguidl.com` - -## Block Explorers - -| Network | Explorer | API | -|---------|----------|-----| -| Mainnet | https://etherscan.io | https://api.etherscan.io | -| Arbitrum | https://arbiscan.io | Etherscan-compatible | -| Base | https://basescan.org | Etherscan-compatible | -| Optimism | https://optimistic.etherscan.io | Etherscan-compatible | - -## MCP Servers for Agents - -**Model Context Protocol** — standard for giving AI agents structured access to external systems. - -1. **Blockscout MCP** — multi-chain blockchain data (primary) -2. **eth-mcp** — community Ethereum RPC via MCP -3. **Custom MCP wrappers** emerging for DeFi protocols, ENS, wallets - -MCP servers are composable — agents can use multiple together. - -## What Changed in 2025-2026 - -- **Foundry became default** over Hardhat for new projects -- **Viem gaining on ethers.js** (smaller, better TypeScript) -- **MCP servers emerged** for agent-blockchain interaction -- **x402 SDKs** went production-ready -- **ERC-8004 tooling** emerging (agent registration/discovery) -- **Deprecated:** Truffle (use Foundry/Hardhat), Goerli/Rinkeby (use Sepolia) - -## Testing Essentials - -**Fork mainnet locally:** -```bash -anvil --fork-url http://erpc.erpc.svc.cluster.local/rpc/mainnet -# Now test against real contracts with fake ETH at http://localhost:8545 -# Fallback public RPC: https://eth.llamarpc.com -``` - -**Primary testnet:** Sepolia (Chain ID: 11155111). Goerli and Rinkeby are deprecated. - -## Related Skills - -- `ethereum-networks` — cast-based blockchain queries (`scripts/rpc.sh`) -- `testing` — Foundry test patterns (forge test, fuzz, fork, invariant) -- `gas` — current gas costs and live estimation commands -- `addresses` — verified contract addresses across chains diff --git a/internal/embed/skills/wallets/SKILL.md b/internal/embed/skills/wallets/SKILL.md index 90cfecc3..a841e145 100644 --- a/internal/embed/skills/wallets/SKILL.md +++ b/internal/embed/skills/wallets/SKILL.md @@ -1,6 +1,6 @@ --- name: wallets -description: How to create, manage, and use Ethereum wallets. Covers EOAs, smart contract wallets, multisig (Safe), and account abstraction. Essential for any AI agent that needs to interact with Ethereum — sending transactions, signing messages, or managing funds. Includes guardrails for safe key handling. +description: Educational reference on Ethereum wallet types — EOAs, smart contract wallets, multisig (Safe), account abstraction (ERC-4337). Use when explaining wallet concepts, choosing a wallet architecture, or designing key management. NOT for sending transactions — use ethereum-local-wallet for that. --- # Wallets on Ethereum diff --git a/internal/openclaw/monetize_integration_test.go b/internal/openclaw/monetize_integration_test.go index 5d26e81b..b0a93965 100644 --- a/internal/openclaw/monetize_integration_test.go +++ b/internal/openclaw/monetize_integration_test.go @@ -300,11 +300,11 @@ func TestIntegration_CRD_Delete(t *testing.T) { // ───────────────────────────────────────────────────────────────────────────── // agentNamespace returns the namespace of the OpenClaw instance that has -// monetize RBAC. This is always the "default" instance ("openclaw-default"). +// monetize RBAC. This is always the "obol-agent" instance ("openclaw-obol-agent"). func agentNamespace(cfg *config.Config) string { out, err := obolRunErr(cfg, "openclaw", "list") if err != nil { - return "openclaw-default" + return "openclaw-obol-agent" } // Collect all namespaces from output. var namespaces []string @@ -317,16 +317,16 @@ func agentNamespace(cfg *config.Config) string { } } } - // Prefer default (has RBAC from `obol agent init`). + // Prefer obol-agent (has RBAC from `obol agent init`). for _, ns := range namespaces { - if ns == "openclaw-default" { + if ns == "openclaw-obol-agent" { return ns } } if len(namespaces) > 0 { return namespaces[0] } - return "openclaw-default" + return "openclaw-obol-agent" } // requireAgent skips the test if no OpenClaw instance is deployed. diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go index 04c06316..d1d105c9 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -123,7 +123,7 @@ func SetupDefault(cfg *config.Config, u *ui.UI) error { } else { u.Successf("Local Ollama detected at %s (no models pulled)", ollamaEndpoint()) u.Print(" Run 'obol model setup' to configure a cloud provider,") - u.Print(" or pull a model with: ollama pull qwen3.5:9b") + u.Print(" or pull a model with: ollama pull qwen3.5:4b") } } else { u.Warnf("Local Ollama not detected on host (%s)", ollamaEndpoint()) @@ -263,7 +263,7 @@ func Onboard(cfg *config.Config, opts OnboardOptions, u *ui.UI) error { agents: defaults: heartbeat: - every: "2m" + every: "5m" target: "none" ` } @@ -2061,17 +2061,14 @@ func listOllamaModels() []string { } // preferredOllamaModel picks the best default model from available Ollama models. -// Prefers qwen3.5:9b if available, otherwise falls back to the first model. +// Models arrive in LiteLLM config order (set by `obol model prefer`), so the +// first model is the user's preferred choice. Falls back to a hardcoded +// preference list only for initial setup when no preference has been set. func preferredOllamaModel(models []string) string { - preferred := []string{"qwen3.5:9b", "qwen3.5:35b", "qwen3.5:27b"} - for _, p := range preferred { - for _, m := range models { - if m == p { - return m - } - } + if len(models) > 0 { + return models[0] } - return models[0] + return "" } // ollamaModelDisplayName converts an Ollama model name (e.g. "llama3.2:3b") diff --git a/internal/tunnel/agent.go b/internal/tunnel/agent.go index 03b90d1f..3656ea7d 100644 --- a/internal/tunnel/agent.go +++ b/internal/tunnel/agent.go @@ -10,7 +10,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/config" ) -const agentDeploymentID = "default" +const agentDeploymentID = "obol-agent" // SyncAgentBaseURL patches AGENT_BASE_URL in the obol-agent's values-obol.yaml // and runs helmfile sync to apply the change. It is a no-op if the obol-agent diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 02fefe94..4927c048 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -102,7 +102,7 @@ func InjectBaseURL(cfg *config.Config, tunnelURL string) error { cmd := exec.Command(kubectlPath, "--kubeconfig", kubeconfigPath, "set", "env", "deployment/openclaw", - "-n", "openclaw-default", + "-n", "openclaw-obol-agent", fmt.Sprintf("AGENT_BASE_URL=%s", strings.TrimRight(tunnelURL, "/")), ) return cmd.Run() diff --git a/internal/x402/bdd_integration_test.go b/internal/x402/bdd_integration_test.go index b17190f5..8a41ae37 100644 --- a/internal/x402/bdd_integration_test.go +++ b/internal/x402/bdd_integration_test.go @@ -162,7 +162,7 @@ func TestMain(m *testing.M) { // Wait for the obol-agent pod to be Running. log.Println(" Waiting for obol-agent pod...") - if err := waitForAnyPod(kubectlBin, kubeconfigPath, "openclaw-default", + if err := waitForAnyPod(kubectlBin, kubeconfigPath, "openclaw-obol-agent", []string{"app=openclaw", "app.kubernetes.io/name=openclaw"}, 300*time.Second); err != nil { teardown(obolBin) log.Fatalf("obol-agent not ready: %v", err) @@ -299,7 +299,7 @@ func ensureExistingClusterBootstrap(obolBin, kubectlBin, kubeconfig string) erro if err := waitForPod(kubectlBin, kubeconfig, "x402", "app=x402-verifier", 120*time.Second); err != nil { return fmt.Errorf("x402-verifier not ready: %w", err) } - if err := waitForAnyPod(kubectlBin, kubeconfig, "openclaw-default", + if err := waitForAnyPod(kubectlBin, kubeconfig, "openclaw-obol-agent", []string{"app=openclaw", "app.kubernetes.io/name=openclaw"}, 180*time.Second); err != nil { return fmt.Errorf("obol-agent not ready: %w", err) } @@ -393,7 +393,7 @@ func waitForServiceOfferReady(kubectlBin, kubeconfig, name, namespace string, ti // This simulates the heartbeat cron firing. func triggerReconciliation(kubectlBin, kubeconfig string) { out, err := kubectl.Output(kubectlBin, kubeconfig, - "exec", "-i", "-n", "openclaw-default", "deploy/openclaw", "-c", "openclaw", + "exec", "-i", "-n", "openclaw-obol-agent", "deploy/openclaw", "-c", "openclaw", "--", "python3", "/data/.openclaw/skills/sell/scripts/monetize.py", "process", "--all") if err != nil { log.Printf(" manual reconciliation error: %v\n%s", err, out) diff --git a/internal/x402/setup.go b/internal/x402/setup.go index b9d644e0..6bc77696 100644 --- a/internal/x402/setup.go +++ b/internal/x402/setup.go @@ -169,7 +169,7 @@ roleRef: subjects: - kind: ServiceAccount name: openclaw - namespace: openclaw-default + namespace: openclaw-obol-agent `) // EnsureVerifier deploys the x402 verifier subsystem if it doesn't exist.