From 816051bdf5c0b348f1e2bc3a91a9dc97bfc4fe09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Tue, 24 Feb 2026 21:32:27 +0000 Subject: [PATCH 1/5] Commit tests --- .github/workflows/wallet-e2e.yml | 88 ++++++ internal/embed/skills/addresses/SKILL.md | 10 + internal/openclaw/wallet_integration_test.go | 295 +++++++++++++++++++ 3 files changed, 393 insertions(+) create mode 100644 .github/workflows/wallet-e2e.yml create mode 100644 internal/openclaw/wallet_integration_test.go diff --git a/.github/workflows/wallet-e2e.yml b/.github/workflows/wallet-e2e.yml new file mode 100644 index 0000000..10cb563 --- /dev/null +++ b/.github/workflows/wallet-e2e.yml @@ -0,0 +1,88 @@ +# Wallet E2E Integration Test +# +# Deploys an OpenClaw instance with a wallet, funds it on Hoodi, +# sends a transaction, and verifies skills work end-to-end. +# +# Required secrets: +# ANTHROPIC_API_KEY — Anthropic API key for LLM provider +# HOODI_FUNDER_PRIVATE_KEY — hex private key of a pre-funded Hoodi testnet wallet + +name: Wallet E2E + +on: + workflow_dispatch: {} + schedule: + # Weekly on Monday at 07:00 UTC — catch regressions without burning credits daily. + - cron: '0 7 * * 1' + +env: + OBOL_CONFIG_DIR: ${{ github.workspace }}/.workspace/config + OBOL_BIN_DIR: ${{ github.workspace }}/.workspace/bin + OBOL_DATA_DIR: ${{ github.workspace }}/.workspace/data + +jobs: + wallet-e2e: + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + + - name: Install k3d + run: | + curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash + + - name: Install helmfile + helm-diff + run: | + curl -fsSL https://github.com/helmfile/helmfile/releases/download/v1.2.3/helmfile_1.2.3_linux_amd64.tar.gz \ + | tar -xzC /usr/local/bin helmfile + helm plugin install https://github.com/databus23/helm-diff --version v3.14.1 + + - name: Build obol binary + run: | + mkdir -p .workspace/bin + go build -o .workspace/bin/obol ./cmd/obol + + - name: Start cluster + run: | + .workspace/bin/obol stack init + .workspace/bin/obol stack up + # Wait for default infrastructure to settle. + sleep 30 + .workspace/bin/obol kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=traefik -n traefik --timeout=120s || true + .workspace/bin/obol kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=erpc -n erpc --timeout=120s || true + + - name: Run wallet E2E test + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + HOODI_FUNDER_PRIVATE_KEY: ${{ secrets.HOODI_FUNDER_PRIVATE_KEY }} + run: | + go test -tags integration -v -run 'TestIntegration_WalletE2E' \ + -timeout 20m ./internal/openclaw/ + + - name: Collect logs on failure + if: failure() + run: | + echo "=== Pod status ===" + .workspace/bin/obol kubectl get pods -A || true + echo "" + echo "=== Events ===" + .workspace/bin/obol kubectl get events -A --sort-by='.lastTimestamp' | tail -50 || true + echo "" + echo "=== OpenClaw logs ===" + .workspace/bin/obol kubectl logs -n openclaw-test-wallet-e2e deploy/openclaw -c openclaw --tail=50 || true + echo "" + echo "=== Remote-signer logs ===" + .workspace/bin/obol kubectl logs -n openclaw-test-wallet-e2e deploy/remote-signer --tail=50 || true + + - name: Tear down cluster + if: always() + run: | + .workspace/bin/obol stack down || true + .workspace/bin/obol stack purge -f || true diff --git a/internal/embed/skills/addresses/SKILL.md b/internal/embed/skills/addresses/SKILL.md index 7e1eaf7..4dd7d6b 100644 --- a/internal/embed/skills/addresses/SKILL.md +++ b/internal/embed/skills/addresses/SKILL.md @@ -315,6 +315,16 @@ Restaking protocol. Both are upgradeable proxies (EIP-1967). Source: [eigenlayer.xyz](https://docs.eigenlayer.xyz/) +### OBOL Token + +The Obol Collective token (ERC-20). + +| Network | Address | Status | +|---------|---------|--------| +| Mainnet | `0x0B010000b7624eb9B3DfBC279673C76E9D29D5F7` | ✅ Verified | + +Source: [docs.obol.org/community-and-governance/obol-token](https://docs.obol.org/community-and-governance/obol-token) + ### Obol Splits — Factory Contracts Obol's Ethereum Validator Manager and reward splitting contracts. Factory contract pattern. Used with splits.org splitter smart contracts and gnosis SAFEs. diff --git a/internal/openclaw/wallet_integration_test.go b/internal/openclaw/wallet_integration_test.go new file mode 100644 index 0000000..2a8cee8 --- /dev/null +++ b/internal/openclaw/wallet_integration_test.go @@ -0,0 +1,295 @@ +//go:build integration + +package openclaw + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// Helpers — wallet-specific +// --------------------------------------------------------------------------- + +// scaffoldWalletInstance creates a deployment with wallet + remote-signer. +// Uses Anthropic via llmspy for the LLM provider. +func scaffoldWalletInstance(t *testing.T, cfg *config.Config, id string, apiKey string) *WalletInfo { + t.Helper() + + deploymentDir := deploymentPath(cfg, id) + if err := os.MkdirAll(deploymentDir, 0755); err != nil { + t.Fatalf("failed to create deployment dir: %v", err) + } + + // Generate wallet (key + keystore + provision to PVC path). + wallet, err := GenerateWallet(cfg, id) + if err != nil { + t.Fatalf("GenerateWallet: %v", err) + } + t.Logf("generated wallet: %s (keystore: %s)", wallet.Address, wallet.KeystoreUUID) + + // Write remote-signer values. + rsValues := generateRemoteSignerValues(wallet) + if err := os.WriteFile(filepath.Join(deploymentDir, "values-remote-signer.yaml"), []byte(rsValues), 0600); err != nil { + t.Fatalf("write remote-signer values: %v", err) + } + + // Write wallet metadata. + if err := writeWalletMetadata(deploymentDir, wallet); err != nil { + t.Fatalf("write wallet metadata: %v", err) + } + + // Configure llmspy with Anthropic key. + cloud := &CloudProviderInfo{ + Name: "anthropic", + APIKey: apiKey, + ModelID: "claude-sonnet-4-5-20250929", + Display: "Claude Sonnet 4.5", + } + imported := buildLLMSpyRoutedOverlay(cloud) + + // Write overlay values (includes REMOTE_SIGNER_URL). + hostname := fmt.Sprintf("openclaw-%s.%s", id, defaultDomain) + namespace := fmt.Sprintf("%s-%s", appName, id) + secretData := collectSensitiveData(imported) + if err := writeUserSecretsFile(deploymentDir, secretData); err != nil { + t.Fatalf("write secrets: %v", err) + } + overlay := generateOverlayValues(hostname, imported, len(secretData) > 0, nil) + if err := os.WriteFile(filepath.Join(deploymentDir, "values-obol.yaml"), []byte(overlay), 0644); err != nil { + t.Fatalf("write overlay: %v", err) + } + + // Write helmfile (openclaw + remote-signer). + helmfileContent := generateHelmfile(id, namespace) + if err := os.WriteFile(filepath.Join(deploymentDir, "helmfile.yaml"), []byte(helmfileContent), 0644); err != nil { + t.Fatalf("write helmfile: %v", err) + } + + // Stage skills. + stageDefaultSkills(deploymentDir) + + return wallet +} + +// waitForRemoteSignerReady waits for the remote-signer pod to be ready. +func waitForRemoteSignerReady(t *testing.T, cfg *config.Config, namespace string) { + t.Helper() + obolRun(t, cfg, "kubectl", + "wait", "--for=condition=ready", "pod", + "-l", "app.kubernetes.io/instance=remote-signer", + "-n", namespace, + "--timeout=180s", + ) +} + +// kubectlExec runs a command inside the openclaw pod and returns stdout. +func kubectlExec(t *testing.T, cfg *config.Config, namespace string, args ...string) string { + t.Helper() + execArgs := append([]string{ + "kubectl", "-n", namespace, + "exec", "deploy/openclaw", "-c", "openclaw", "--", + }, args...) + return obolRun(t, cfg, execArgs...) +} + +// kubectlExecErr runs a command inside the openclaw pod, returning output + error. +func kubectlExecErr(cfg *config.Config, namespace string, args ...string) (string, error) { + execArgs := append([]string{ + "kubectl", "-n", namespace, + "exec", "deploy/openclaw", "-c", "openclaw", "--", + }, args...) + return obolRunErr(cfg, execArgs...) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +// TestIntegration_WalletE2E deploys an OpenClaw instance with a wallet, +// funds it on Hoodi, sends a transaction, and verifies it succeeds. +// It also exercises the ethereum-networks skill for read-only queries. +// +// Required environment: +// +// ANTHROPIC_API_KEY — for LLM provider (routed through llmspy) +// HOODI_FUNDER_PRIVATE_KEY — hex private key of a pre-funded Hoodi wallet +func TestIntegration_WalletE2E(t *testing.T) { + cfg := requireCluster(t) + apiKey := requireEnvKey(t, "ANTHROPIC_API_KEY") + funderKey := requireEnvKey(t, "HOODI_FUNDER_PRIVATE_KEY") + + const id = "test-wallet-e2e" + t.Cleanup(func() { cleanupInstance(t, cfg, id) }) + + // 1. Scaffold and deploy instance with wallet + remote-signer. + t.Log("scaffolding OpenClaw instance with wallet...") + wallet := scaffoldWalletInstance(t, cfg, id, apiKey) + + // Configure llmspy gateway. + obolRun(t, cfg, "model", "setup", "--provider", "anthropic", "--api-key", apiKey) + + t.Log("deploying via: obol openclaw sync " + id) + obolRun(t, cfg, "openclaw", "sync", id) + + namespace := fmt.Sprintf("%s-%s", appName, id) + + // 2. Wait for both pods to be ready. + t.Log("waiting for openclaw pod...") + waitForPodReady(t, cfg, namespace) + t.Log("waiting for remote-signer pod...") + waitForRemoteSignerReady(t, cfg, namespace) + + // 3. Verify remote-signer health from inside the pod. + t.Run("remote-signer/health", func(t *testing.T) { + out := kubectlExec(t, cfg, namespace, + "python3", "/data/.openclaw/skills/ethereum-local-wallet/scripts/signer.py", "health") + t.Logf("remote-signer health: %s", strings.TrimSpace(out)) + if !strings.Contains(out, "ok") { + t.Fatalf("remote-signer unhealthy: %s", out) + } + }) + + // 4. Verify wallet address is listed. + t.Run("remote-signer/accounts", func(t *testing.T) { + out := kubectlExec(t, cfg, namespace, + "python3", "/data/.openclaw/skills/ethereum-local-wallet/scripts/signer.py", "accounts") + t.Logf("accounts output: %s", strings.TrimSpace(out)) + // The address from wallet generation should appear (lowercase compare). + addrLower := strings.ToLower(wallet.Address) + if !strings.Contains(strings.ToLower(out), addrLower[2:]) { + t.Fatalf("expected wallet address %s in accounts output", wallet.Address) + } + }) + + // 5. Fund the wallet on Hoodi using cast inside the pod. + t.Run("hoodi/fund-wallet", func(t *testing.T) { + // Send 0.01 ETH from funder to agent wallet on Hoodi. + rpcURL := "http://erpc.erpc.svc.cluster.local:4000/rpc/hoodi" + t.Logf("funding %s with 0.01 ETH on Hoodi...", wallet.Address) + + out := kubectlExec(t, cfg, namespace, + "cast", "send", + "--private-key", funderKey, + "--rpc-url", rpcURL, + "--value", "10000000000000000", // 0.01 ETH + wallet.Address, + ) + t.Logf("funding tx: %s", strings.TrimSpace(out)) + + // Verify the agent wallet received funds. + balOut := kubectlExec(t, cfg, namespace, + "cast", "balance", "--ether", "--rpc-url", rpcURL, wallet.Address, + ) + t.Logf("agent balance: %s ETH", strings.TrimSpace(balOut)) + }) + + // 6. Sign and send a transaction from the agent wallet on Hoodi. + t.Run("hoodi/send-tx", func(t *testing.T) { + // Send a tiny amount back to the funder (or a burn address). + // We use signer.py send-tx which auto-fills nonce/gas from eRPC. + burnAddr := "0x000000000000000000000000000000000000dEaD" + out := kubectlExec(t, cfg, namespace, + "python3", "/data/.openclaw/skills/ethereum-local-wallet/scripts/signer.py", + "send-tx", + "--from", wallet.Address, + "--to", burnAddr, + "--value", "1000000000000", // 0.000001 ETH + "--network", "hoodi", + ) + t.Logf("send-tx output:\n%s", out) + + // Verify transaction hash is in output. + if !strings.Contains(out, "0x") { + t.Fatalf("no transaction hash in output") + } + // Verify success status. + if strings.Contains(out, "reverted") { + t.Fatal("transaction reverted") + } + }) + + // 7. Sign a message (no network needed). + t.Run("sign-message", func(t *testing.T) { + out := kubectlExec(t, cfg, namespace, + "python3", "/data/.openclaw/skills/ethereum-local-wallet/scripts/signer.py", + "sign-msg", wallet.Address, "Hello from Obol Stack integration test", + ) + sig := strings.TrimSpace(out) + t.Logf("message signature: %s", sig) + // EIP-191 signature = 65 bytes = 132 hex chars + 0x prefix = 132 chars. + if !strings.HasPrefix(sig, "0x") || len(sig) != 132 { + t.Fatalf("invalid signature length: got %d chars, want 132", len(sig)) + } + }) + + // 8. Wallet metadata ConfigMap exists. + t.Run("wallet-metadata-configmap", func(t *testing.T) { + out := obolRun(t, cfg, "kubectl", "-n", namespace, + "get", "configmap", "wallet-metadata", "-o", "jsonpath={.data.addresses\\.json}") + t.Logf("wallet-metadata: %s", out) + + var metadata struct { + Addresses []struct { + Address string `json:"address"` + } `json:"addresses"` + Count int `json:"count"` + } + if err := json.Unmarshal([]byte(out), &metadata); err != nil { + t.Fatalf("parse wallet-metadata: %v", err) + } + if metadata.Count < 1 { + t.Fatal("expected at least 1 address in wallet-metadata") + } + if !strings.EqualFold(metadata.Addresses[0].Address, wallet.Address) { + t.Errorf("address mismatch: got %s, want %s", metadata.Addresses[0].Address, wallet.Address) + } + }) + + // 9. Ethereum-networks skill — read-only queries via rpc.sh (cast). + t.Run("ethereum-networks/hoodi-chain-id", func(t *testing.T) { + out := kubectlExec(t, cfg, namespace, + "sh", "/data/.openclaw/skills/ethereum-networks/scripts/rpc.sh", + "--network", "hoodi", "chain-id", + ) + chainID := strings.TrimSpace(out) + t.Logf("Hoodi chain ID: %s", chainID) + if chainID != "560048" { + t.Errorf("expected chain ID 560048, got %s", chainID) + } + }) + + t.Run("ethereum-networks/hoodi-block", func(t *testing.T) { + out := kubectlExec(t, cfg, namespace, + "sh", "/data/.openclaw/skills/ethereum-networks/scripts/rpc.sh", + "--network", "hoodi", "block", "latest", + ) + t.Logf("latest block output (first 200 chars): %.200s", out) + if !strings.Contains(out, "number") { + t.Error("block output missing 'number' field") + } + }) + + t.Run("ethereum-networks/hoodi-balance", func(t *testing.T) { + out := kubectlExec(t, cfg, namespace, + "sh", "/data/.openclaw/skills/ethereum-networks/scripts/rpc.sh", + "--network", "hoodi", "balance", wallet.Address, + ) + t.Logf("agent Hoodi balance: %s", strings.TrimSpace(out)) + }) + + t.Run("ethereum-networks/mainnet-gas-price", func(t *testing.T) { + out := kubectlExec(t, cfg, namespace, + "sh", "/data/.openclaw/skills/ethereum-networks/scripts/rpc.sh", + "gas-price", + ) + t.Logf("mainnet gas price: %s", strings.TrimSpace(out)) + if strings.TrimSpace(out) == "" || strings.TrimSpace(out) == "0" { + t.Error("gas price should be > 0") + } + }) +} From 082bc328dd0c99c3c5e4e380968496f480eddabb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Tue, 24 Feb 2026 21:36:30 +0000 Subject: [PATCH 2/5] Trigger test --- .github/workflows/wallet-e2e.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/wallet-e2e.yml b/.github/workflows/wallet-e2e.yml index 10cb563..6b4ad0d 100644 --- a/.github/workflows/wallet-e2e.yml +++ b/.github/workflows/wallet-e2e.yml @@ -10,6 +10,10 @@ name: Wallet E2E on: + # Temporary + push: + branches: + - oisin/integration workflow_dispatch: {} schedule: # Weekly on Monday at 07:00 UTC — catch regressions without burning credits daily. From 507be666ee5c1937494fdd944d44492d6cf3ff5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Tue, 24 Feb 2026 21:39:57 +0000 Subject: [PATCH 3/5] Fix with symlink --- .github/workflows/wallet-e2e.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/wallet-e2e.yml b/.github/workflows/wallet-e2e.yml index 6b4ad0d..6f96da5 100644 --- a/.github/workflows/wallet-e2e.yml +++ b/.github/workflows/wallet-e2e.yml @@ -48,10 +48,16 @@ jobs: | tar -xzC /usr/local/bin helmfile helm plugin install https://github.com/databus23/helm-diff --version v3.14.1 - - name: Build obol binary + - name: Build obol binary and link dependencies run: | mkdir -p .workspace/bin go build -o .workspace/bin/obol ./cmd/obol + # obol looks for tools in OBOL_BIN_DIR — symlink the globally-installed ones. + for bin in k3d helm helmfile kubectl; do + if command -v "$bin" &>/dev/null && [ ! -e ".workspace/bin/$bin" ]; then + ln -s "$(command -v "$bin")" ".workspace/bin/$bin" + fi + done - name: Start cluster run: | From ab3fcb0bf986ed8ee2b76151f8b633b2b300f7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Tue, 24 Feb 2026 21:44:40 +0000 Subject: [PATCH 4/5] Config missing --- internal/openclaw/wallet_integration_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/openclaw/wallet_integration_test.go b/internal/openclaw/wallet_integration_test.go index 2a8cee8..7db5c44 100644 --- a/internal/openclaw/wallet_integration_test.go +++ b/internal/openclaw/wallet_integration_test.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/ObolNetwork/obol-stack/internal/config" ) // --------------------------------------------------------------------------- From a345ffcec9e2f2d2287b0a6d4edf9420088a2d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Tue, 24 Feb 2026 23:45:38 +0000 Subject: [PATCH 5/5] Work on permissions --- internal/openclaw/wallet.go | 16 ++++++++++++++++ internal/openclaw/wallet_integration_test.go | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/openclaw/wallet.go b/internal/openclaw/wallet.go index 9d0fa1c..84a898d 100644 --- a/internal/openclaw/wallet.go +++ b/internal/openclaw/wallet.go @@ -335,6 +335,22 @@ func provisionKeystoreToVolume(cfg *config.Config, id, keystoreID string, keysto return "", fmt.Errorf("write keystore: %w", err) } + // Chown to the remote-signer's UID/GID (65532 — distroless nonroot). + // This runs on the host before the pod starts. On Linux (including + // k3d's Docker host), this works without root because we own the files. + // On macOS, chown to a non-existent UID requires root but k3d runs + // inside Docker where the volume is accessed by the k3d container's root. + const remoteSignerUID = 65532 + const remoteSignerGID = 65532 + if err := os.Chown(dir, remoteSignerUID, remoteSignerGID); err != nil { + // Non-fatal: may fail on macOS without sudo. The remote-signer + // chart has a fix-permissions init container as a fallback. + fmt.Printf("Warning: could not chown keystore dir to UID %d: %v\n", remoteSignerUID, err) + } + if err := os.Chown(path, remoteSignerUID, remoteSignerGID); err != nil { + fmt.Printf("Warning: could not chown keystore file to UID %d: %v\n", remoteSignerUID, err) + } + return path, nil } diff --git a/internal/openclaw/wallet_integration_test.go b/internal/openclaw/wallet_integration_test.go index 7db5c44..baabdcd 100644 --- a/internal/openclaw/wallet_integration_test.go +++ b/internal/openclaw/wallet_integration_test.go @@ -171,7 +171,7 @@ func TestIntegration_WalletE2E(t *testing.T) { // 5. Fund the wallet on Hoodi using cast inside the pod. t.Run("hoodi/fund-wallet", func(t *testing.T) { // Send 0.01 ETH from funder to agent wallet on Hoodi. - rpcURL := "http://erpc.erpc.svc.cluster.local:4000/rpc/hoodi" + rpcURL := "http://rpc.erpc.svc.cluster.local/hoodi" t.Logf("funding %s with 0.01 ETH on Hoodi...", wallet.Address) out := kubectlExec(t, cfg, namespace,