diff --git a/.github/workflows/docker-publish-openclaw.yml b/.github/workflows/docker-publish-openclaw.yml
index da3a862..c0967d7 100644
--- a/.github/workflows/docker-publish-openclaw.yml
+++ b/.github/workflows/docker-publish-openclaw.yml
@@ -18,7 +18,7 @@ on:
required: false
type: string
foundry_version:
- description: 'Foundry nightly tag (e.g. nightly-abc123...). Defaults to pinned version.'
+ description: 'Foundry version tag (e.g. v1.5.1). Defaults to pinned version.'
required: false
type: string
diff --git a/CLAUDE.md b/CLAUDE.md
index 42f20be..ab949ce 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -802,7 +802,7 @@ Skills are delivered by writing directly to the host filesystem at `$DATA_DIR/op
**Advantages over ConfigMap approach**: No 1MB size limit, works before pod readiness, survives pod restarts, supports binary files and scripts.
-### Default Skills (20 skills)
+### Default Skills (21 skills)
The stack ships 20 embedded skills organized into categories. All are installed automatically on first deploy.
@@ -811,6 +811,7 @@ The stack ships 20 embedded skills organized into categories. All are installed
| Skill | Contents | Purpose |
|-------|----------|---------|
| `ethereum-networks` | `SKILL.md`, `scripts/rpc.sh`, `scripts/rpc.py`, `references/erc20-methods.md`, `references/common-contracts.md` | Read-only Ethereum queries via cast/eRPC — blocks, balances, contract reads, ERC-20 lookups, ENS resolution |
+| `ethereum-local-wallet` | `SKILL.md`, `scripts/signer.py`, `references/remote-signer-api.md` | Sign and send Ethereum transactions via the per-agent remote-signer service |
| `obol-stack` | `SKILL.md`, `scripts/kube.py` | Kubernetes cluster diagnostics — pods, logs, events, deployments via ServiceAccount API |
| `distributed-validators` | `SKILL.md`, `references/api-examples.md` | Obol DVT cluster monitoring, operator audit, exit coordination via Obol API |
@@ -875,7 +876,44 @@ obol openclaw skills sync # re-inject embedded defaults to vol
obol openclaw skills sync --from
# push custom skills from local directory
```
-### Key Source Files
+### Ethereum Local Wallet (Remote-Signer)
+
+Each OpenClaw instance is provisioned with an Ethereum signing wallet during `obol openclaw onboard`. The wallet is backed by a remote-signer service (Rust-based REST API) deployed in the same namespace.
+
+**Architecture**:
+```
+Namespace: openclaw-
+ OpenClaw Pod ──HTTP:9000──> remote-signer Pod
+ (signer.py skill) /data/keystores/.json (V3)
+ │
+ └── eth_sendRawTransaction ──> eRPC (:4000/rpc)
+```
+
+**Key generation**: secp256k1 via `crypto/rand` + `github.com/decred/dcrd/dcrec/secp256k1/v4`, encrypted to Web3 Secret Storage V3 format (scrypt + AES-128-CTR).
+
+**Provisioning flow**:
+1. `GenerateWallet()` creates key + V3 keystore + random password
+2. Keystore written to host PVC path: `$DATA_DIR/openclaw-/remote-signer-keystores/`
+3. Password stored in `values-remote-signer.yaml` for the Helm chart
+4. `generateHelmfile()` includes both `obol/openclaw` and `obol/remote-signer` releases
+5. After helmfile sync, `applyWalletMetadataConfigMap()` creates a `wallet-metadata` ConfigMap for the frontend
+
+**Remote-signer API** (ClusterIP, port 9000):
+- `GET /api/v1/keys` — list signing addresses
+- `POST /api/v1/sign/{address}/transaction` — sign EIP-1559 tx
+- `POST /api/v1/sign/{address}/message` — sign EIP-191 message
+- `POST /api/v1/sign/{address}/typed-data` — sign EIP-712 typed data
+- `POST /api/v1/sign/{address}/hash` — sign raw hash
+
+**Key source files**:
+
+| File | Role |
+|------|------|
+| `internal/openclaw/wallet.go` | Key generation, V3 keystore encryption, provisioning, ConfigMap creation |
+| `internal/openclaw/wallet_test.go` | Unit tests for key gen, encrypt/decrypt round-trip, address derivation |
+| `internal/embed/skills/ethereum-local-wallet/` | Signing skill (SKILL.md, scripts/signer.py, references/) |
+
+### Key Source Files (Skills)
| File | Role |
|------|------|
@@ -1143,7 +1181,7 @@ obol network delete ethereum- --force
- `aztec/helmfile.yaml.gotmpl`
- `internal/embed/defaults/` - Default stack resources
- `internal/embed/infrastructure/` - Infrastructure resources (llmspy, Traefik)
-- `internal/embed/skills/` - Default OpenClaw skills (20 skills) embedded in obol binary
+- `internal/embed/skills/` - Default OpenClaw skills (21 skills) embedded in obol binary
**Skills system**:
- `internal/openclaw/resolve.go` - Smart instance resolution (0/1/2+ instances)
diff --git a/README.md b/README.md
index a3aade2..257e29c 100644
--- a/README.md
+++ b/README.md
@@ -163,13 +163,14 @@ When only one OpenClaw instance is installed, the instance ID is optional — it
### Skills
-OpenClaw ships with 20 embedded skills that are installed automatically on first deploy. Skills give the agent domain-specific capabilities — from querying blockchains to understanding Ethereum development patterns.
+OpenClaw ships with 21 embedded skills that are installed automatically on first deploy. Skills give the agent domain-specific capabilities — from querying blockchains to understanding Ethereum development patterns.
#### Infrastructure
| Skill | Purpose |
|-------|---------|
| `ethereum-networks` | Read-only Ethereum queries via cast — blocks, balances, contract reads, ERC-20, ENS |
+| `ethereum-local-wallet` | Sign and send Ethereum transactions via the per-agent remote-signer |
| `obol-stack` | Kubernetes cluster diagnostics — pods, logs, events, deployments |
| `distributed-validators` | Obol DVT cluster monitoring, operator audit, exit coordination |
diff --git a/docker/openclaw/Dockerfile b/docker/openclaw/Dockerfile
index c9d2272..10bd91c 100644
--- a/docker/openclaw/Dockerfile
+++ b/docker/openclaw/Dockerfile
@@ -3,7 +3,7 @@
# after the base image is built from upstream source.
#
# Usage (CI):
-# docker build --build-arg BASE_TAG=v2026.2.15 --build-arg FOUNDRY_TAG=nightly-... \
+# docker build --build-arg BASE_TAG=v2026.2.23 --build-arg FOUNDRY_TAG=v1.5.1 \
# -f docker/openclaw/Dockerfile .
#
ARG FOUNDRY_TAG
diff --git a/go.mod b/go.mod
index eb85a34..348f785 100644
--- a/go.mod
+++ b/go.mod
@@ -3,9 +3,12 @@ module github.com/ObolNetwork/obol-stack
go 1.25.1
require (
+ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0
+ github.com/google/uuid v1.6.0
github.com/mark3labs/x402-go v0.13.0
github.com/urfave/cli/v2 v2.27.7
+ golang.org/x/crypto v0.45.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -19,7 +22,6 @@ require (
github.com/gagliardetto/binary v0.8.0 // indirect
github.com/gagliardetto/solana-go v1.14.0 // indirect
github.com/gagliardetto/treeout v0.1.4 // indirect
- github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
@@ -37,7 +39,6 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
go.uber.org/zap v1.27.0 // indirect
- golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect
diff --git a/go.sum b/go.sum
index c23a960..c9bd698 100644
--- a/go.sum
+++ b/go.sum
@@ -12,6 +12,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk=
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
diff --git a/internal/embed/embed_skills_test.go b/internal/embed/embed_skills_test.go
index 2aa1bd1..727ddef 100644
--- a/internal/embed/embed_skills_test.go
+++ b/internal/embed/embed_skills_test.go
@@ -16,7 +16,7 @@ func TestGetEmbeddedSkillNames(t *testing.T) {
// Core skills that must always be present
coreSkills := []string{
"addresses", "building-blocks", "concepts", "distributed-validators",
- "ethereum-networks", "frontend-playbook", "frontend-ux", "gas",
+ "ethereum-networks", "ethereum-local-wallet", "frontend-playbook", "frontend-ux", "gas",
"indexing", "l2s", "obol-stack", "orchestration", "qa", "security",
"ship", "standards", "testing", "tools", "wallets", "why",
}
@@ -45,7 +45,7 @@ func TestCopySkills(t *testing.T) {
}
// Every skill must have a SKILL.md
- skills := []string{"distributed-validators", "ethereum-networks", "obol-stack", "addresses", "wallets"}
+ skills := []string{"distributed-validators", "ethereum-networks", "ethereum-local-wallet", "obol-stack", "addresses", "wallets"}
for _, skill := range skills {
skillMD := filepath.Join(destDir, skill, "SKILL.md")
info, err := os.Stat(skillMD)
@@ -69,6 +69,16 @@ func TestCopySkills(t *testing.T) {
}
}
+ // ethereum-local-wallet must have scripts/signer.py and references/
+ for _, sub := range []string{
+ "ethereum-local-wallet/scripts/signer.py",
+ "ethereum-local-wallet/references/remote-signer-api.md",
+ } {
+ if _, err := os.Stat(filepath.Join(destDir, sub)); err != nil {
+ t.Errorf("missing %s: %v", sub, err)
+ }
+ }
+
// obol-stack must have scripts/kube.py
if _, err := os.Stat(filepath.Join(destDir, "obol-stack", "scripts", "kube.py")); err != nil {
t.Errorf("missing obol-stack/scripts/kube.py: %v", err)
diff --git a/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl b/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl
index f8a4833..22e52c7 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.8"
+ tag: "v0.1.9"
service:
type: ClusterIP
diff --git a/internal/embed/skills/ethereum-local-wallet/SKILL.md b/internal/embed/skills/ethereum-local-wallet/SKILL.md
new file mode 100644
index 0000000..b4c73e8
--- /dev/null
+++ b/internal/embed/skills/ethereum-local-wallet/SKILL.md
@@ -0,0 +1,110 @@
+---
+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."
+metadata: { "openclaw": { "emoji": "💳", "requires": { "bins": ["python3"] } } }
+---
+
+# Ethereum Wallet
+
+Sign and send Ethereum transactions through the local remote-signer service. The agent never accesses private keys directly — all signing is done via HTTP API calls to the remote-signer, which holds encrypted keystores.
+
+## When to Use
+
+- Sending ETH or ERC-20 tokens
+- Signing messages (EIP-191) or typed data (EIP-712)
+- Interacting with smart contracts that modify state (write operations)
+- Approving token allowances
+- Any operation that requires a transaction signature
+
+## When NOT to Use
+
+- **Read-only queries** — use the `ethereum-networks` skill instead (`rpc.sh`)
+- **Key generation** — keys are managed by the `obol` CLI, not by the agent
+- **Cross-chain bridges** — not supported in this skill
+
+## Quick Start
+
+```bash
+# List your signing addresses
+python3 scripts/signer.py accounts
+
+# Check remote-signer health
+python3 scripts/signer.py health
+
+# Sign a message
+python3 scripts/signer.py sign-msg 0xYOUR_ADDRESS "Hello, Ethereum!"
+
+# Sign a transaction (returns raw signed tx hex, does NOT broadcast)
+python3 scripts/signer.py sign-tx \
+ --from 0xYOUR_ADDRESS --to 0xRECIPIENT \
+ --value 1000000000000000000 --network mainnet
+
+# Sign AND broadcast a transaction via eRPC
+python3 scripts/signer.py send-tx \
+ --from 0xYOUR_ADDRESS --to 0xRECIPIENT \
+ --value 1000000000000000000 --network mainnet
+```
+
+## Available Commands
+
+| Command | Description |
+|---------|-------------|
+| `accounts` | List all signing addresses from the remote-signer |
+| `health` | Check remote-signer `/healthz` endpoint |
+| `sign ` | Sign a raw 32-byte hash |
+| `sign-msg ` | Sign a message with EIP-191 prefix |
+| `sign-tx --from --to [--value] [--data] [--gas] [--nonce] [--network]` | Sign an EIP-1559 transaction |
+| `send-tx --from --to [--value] [--data] [--network]` | Sign AND broadcast a transaction via eRPC |
+| `sign-typed ` | Sign EIP-712 typed data |
+
+## Transaction Submission Flow
+
+```
+1. Agent decides to send a transaction
+2. signer.py: GET /api/v1/keys → list available signing addresses
+3. signer.py: eth_getTransactionCount → eRPC (nonce)
+4. signer.py: eth_gasPrice + eth_maxPriorityFeePerGas → eRPC (fees)
+5. signer.py: eth_estimateGas → eRPC (gas limit)
+6. signer.py: POST /api/v1/sign/{address}/transaction → remote-signer
+ → Returns RLP-encoded signed transaction hex
+7. signer.py: eth_sendRawTransaction → eRPC (broadcast)
+8. signer.py: eth_getTransactionReceipt → eRPC (confirmation)
+```
+
+## Multi-Network Support
+
+The same signing key works on any EVM chain. Specify the network via `--network`:
+
+```bash
+python3 scripts/signer.py send-tx --network hoodi \
+ --from 0x... --to 0x... --value 1000000000000000000
+```
+
+Supported networks: `mainnet`, `hoodi`, `sepolia` (depends on eRPC configuration).
+
+## Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `REMOTE_SIGNER_URL` | `http://remote-signer:9000` | Remote-signer REST API base URL |
+| `ERPC_URL` | `http://erpc.erpc.svc.cluster.local:4000/rpc` | eRPC gateway for RPC calls |
+| `ERPC_NETWORK` | `mainnet` | Default network for RPC routing |
+
+## Security Model
+
+- **Agent never touches keys**: All signing via HTTP to the remote-signer
+- **ClusterIP isolation**: Remote-signer is only reachable within the cluster
+- **Encrypted keystores**: V3 Web3 Secret Storage format with scrypt KDF
+- **Values in wei**: No automatic unit conversion — always specify amounts in wei
+
+## Important Notes
+
+- Always **confirm with the user** before sending transactions
+- Values are in **wei** (1 ETH = 1000000000000000000 wei)
+- Use `ethereum-networks` skill (`rpc.sh balance `) to check balances before sending
+- The `send-tx` command will broadcast the transaction immediately after signing
+
+## See Also
+
+- `ethereum-networks` — read-only blockchain queries via eRPC
+- `references/remote-signer-api.md` — full remote-signer REST API reference
diff --git a/internal/embed/skills/ethereum-local-wallet/references/remote-signer-api.md b/internal/embed/skills/ethereum-local-wallet/references/remote-signer-api.md
new file mode 100644
index 0000000..b3c7246
--- /dev/null
+++ b/internal/embed/skills/ethereum-local-wallet/references/remote-signer-api.md
@@ -0,0 +1,100 @@
+# Remote-Signer REST API Reference
+
+Base URL: `$REMOTE_SIGNER_URL` (default: `http://remote-signer:9000`)
+
+## Health
+
+| Method | Path | Response |
+|--------|------|----------|
+| GET | `/healthz` | `{"status": "ok"}` |
+| GET | `/upcheck` | `OK` (text) |
+
+## Key Management
+
+| Method | Path | Response |
+|--------|------|----------|
+| GET | `/api/v1/keys` | `{"keys": ["0xABC...", "0xDEF..."]}` |
+| POST | `/api/v1/keystores/reload` | `{"keys_loaded": N}` |
+
+## Signing
+
+All signing endpoints: `POST /api/v1/sign/{address}/{operation}`
+
+### Sign Transaction (EIP-1559)
+
+```
+POST /api/v1/sign/0x{ADDRESS}/transaction
+Content-Type: application/json
+
+{
+ "chain_id": 1,
+ "to": "0x...",
+ "nonce": 42,
+ "gas_limit": 21000,
+ "max_fee_per_gas": 30000000000,
+ "max_priority_fee_per_gas": 1000000000,
+ "value": "0x0",
+ "data": "0x"
+}
+
+→ 200: {"signed_transaction": "0x02f8..."}
+```
+
+Gas fields accept JSON numbers or hex strings.
+
+### Sign Message (EIP-191)
+
+```
+POST /api/v1/sign/0x{ADDRESS}/message
+Content-Type: application/json
+
+{"message": "Hello, Ethereum!"}
+
+→ 200: {"signature": "0x..."}
+```
+
+Supports plain text or `0x`-prefixed hex messages.
+
+### Sign Typed Data (EIP-712)
+
+```
+POST /api/v1/sign/0x{ADDRESS}/typed-data
+Content-Type: application/json
+
+{
+ "types": {"EIP712Domain": [...], "Mail": [...]},
+ "primaryType": "Mail",
+ "domain": {"name": "Example", "chainId": 1},
+ "message": {"contents": "Hello"}
+}
+
+→ 200: {"signature": "0x..."}
+```
+
+### Sign Raw Hash
+
+```
+POST /api/v1/sign/0x{ADDRESS}/hash
+Content-Type: application/json
+
+{"hash": "0x0000...0001"}
+
+→ 200: {"signature": "0x..."}
+```
+
+## Error Responses
+
+```json
+{"error": "...", "code": "SIGNER_NOT_FOUND", "address": "0x..."}
+```
+
+| Status | Code | Meaning |
+|--------|------|---------|
+| 400 | `BAD_REQUEST` | Malformed request body |
+| 404 | `SIGNER_NOT_FOUND` | Address not loaded |
+| 422 | `MISSING_FIELD` | Missing required field |
+| 500 | `SIGNING_ERROR` | Internal signing failure |
+
+## Signature Format
+
+65 bytes: `r (32) || s (32) || v (1)`, hex-encoded with `0x` prefix (132 chars total).
diff --git a/internal/embed/skills/ethereum-local-wallet/scripts/signer.py b/internal/embed/skills/ethereum-local-wallet/scripts/signer.py
new file mode 100644
index 0000000..3eca0a8
--- /dev/null
+++ b/internal/embed/skills/ethereum-local-wallet/scripts/signer.py
@@ -0,0 +1,321 @@
+#!/usr/bin/env python3
+"""signer.py — Sign and send Ethereum transactions via the remote-signer REST API.
+
+Uses only Python stdlib. No web3, eth_abi, or external packages required.
+
+Usage: python3 scripts/signer.py [--network ] [args...]
+
+Environment:
+ REMOTE_SIGNER_URL Base URL for remote-signer (default: http://remote-signer:9000)
+ ERPC_URL Base URL for eRPC gateway (default: http://erpc.erpc.svc.cluster.local:4000/rpc)
+ ERPC_NETWORK Default network (default: mainnet)
+"""
+import json
+import os
+import sys
+import urllib.request
+import urllib.error
+
+SIGNER_URL = os.environ.get("REMOTE_SIGNER_URL", "http://remote-signer:9000")
+ERPC_BASE = os.environ.get("ERPC_URL", "http://erpc.erpc.svc.cluster.local:4000/rpc")
+NETWORK = os.environ.get("ERPC_NETWORK", "mainnet")
+
+# Chain IDs for known networks.
+CHAIN_IDS = {
+ "mainnet": 1,
+ "hoodi": 560048,
+ "sepolia": 11155111,
+}
+
+
+def _signer_get(path):
+ """GET request to the remote-signer."""
+ url = f"{SIGNER_URL}{path}"
+ req = urllib.request.Request(url, method="GET")
+ try:
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ return json.loads(resp.read())
+ except urllib.error.HTTPError as e:
+ body = e.read().decode()
+ print(f"Error ({e.code}): {body}", file=sys.stderr)
+ sys.exit(1)
+ except urllib.error.URLError as e:
+ print(f"Connection error: {e.reason}", file=sys.stderr)
+ print(f"Is the remote-signer running at {SIGNER_URL}?", file=sys.stderr)
+ sys.exit(1)
+
+
+def _signer_post(path, data):
+ """POST JSON to the remote-signer."""
+ url = f"{SIGNER_URL}{path}"
+ payload = json.dumps(data).encode()
+ req = urllib.request.Request(
+ url, data=payload, method="POST",
+ headers={"Content-Type": "application/json"},
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ return json.loads(resp.read())
+ except urllib.error.HTTPError as e:
+ body = e.read().decode()
+ print(f"Error ({e.code}): {body}", file=sys.stderr)
+ sys.exit(1)
+ except urllib.error.URLError as e:
+ print(f"Connection error: {e.reason}", file=sys.stderr)
+ sys.exit(1)
+
+
+def _rpc_call(method, params=None, network=None):
+ """JSON-RPC call to eRPC."""
+ net = network or NETWORK
+ url = f"{ERPC_BASE}/{net}"
+ payload = json.dumps({
+ "jsonrpc": "2.0",
+ "method": method,
+ "params": params or [],
+ "id": 1,
+ }).encode()
+ req = urllib.request.Request(
+ url, data=payload, method="POST",
+ headers={"Content-Type": "application/json"},
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ result = json.loads(resp.read())
+ if "error" in result:
+ print(f"RPC error: {result['error']}", file=sys.stderr)
+ sys.exit(1)
+ return result.get("result")
+ except urllib.error.URLError as e:
+ print(f"eRPC connection error: {e.reason}", file=sys.stderr)
+ sys.exit(1)
+
+
+def cmd_accounts():
+ """List signing addresses."""
+ data = _signer_get("/api/v1/keys")
+ keys = data.get("keys", [])
+ if not keys:
+ print("No signing keys loaded.")
+ return
+ print(f"Signing addresses ({len(keys)}):")
+ for addr in keys:
+ print(f" {addr}")
+
+
+def cmd_health():
+ """Check remote-signer health."""
+ data = _signer_get("/healthz")
+ print(f"Status: {data.get('status', 'unknown')}")
+
+
+def cmd_sign(address, hex_data):
+ """Sign a raw 32-byte hash."""
+ if not hex_data.startswith("0x"):
+ hex_data = "0x" + hex_data
+ data = _signer_post(f"/api/v1/sign/{address}/hash", {"hash": hex_data})
+ print(data.get("signature", ""))
+
+
+def cmd_sign_msg(address, message):
+ """Sign a message (EIP-191)."""
+ data = _signer_post(f"/api/v1/sign/{address}/message", {"message": message})
+ print(data.get("signature", ""))
+
+
+def cmd_sign_typed(address, typed_data_json):
+ """Sign EIP-712 typed data."""
+ typed_data = json.loads(typed_data_json)
+ data = _signer_post(f"/api/v1/sign/{address}/typed-data", typed_data)
+ print(data.get("signature", ""))
+
+
+def cmd_sign_tx(args):
+ """Sign an EIP-1559 transaction. Auto-fills nonce, gas, and fees from eRPC."""
+ opts = _parse_tx_flags(args)
+ network = opts.get("network", NETWORK)
+ chain_id = CHAIN_IDS.get(network, 1)
+ from_addr = opts["from"]
+ to_addr = opts["to"]
+ value = opts.get("value", "0x0")
+ call_data = opts.get("data", "0x")
+
+ # Auto-fill nonce.
+ nonce = opts.get("nonce")
+ if nonce is None:
+ nonce_hex = _rpc_call("eth_getTransactionCount", [from_addr, "pending"], network)
+ nonce = int(nonce_hex, 16)
+ else:
+ nonce = int(nonce)
+
+ # Auto-fill gas fees.
+ max_fee = opts.get("max_fee")
+ max_priority = opts.get("max_priority")
+ if max_fee is None or max_priority is None:
+ base_fee_hex = _rpc_call("eth_gasPrice", [], network)
+ base_fee = int(base_fee_hex, 16)
+ if max_priority is None:
+ try:
+ priority_hex = _rpc_call("eth_maxPriorityFeePerGas", [], network)
+ max_priority = int(priority_hex, 16)
+ except SystemExit:
+ max_priority = 1_000_000_000 # 1 gwei fallback
+ else:
+ max_priority = int(max_priority)
+ if max_fee is None:
+ max_fee = base_fee * 2 + max_priority
+ else:
+ max_fee = int(max_fee)
+
+ # Auto-fill gas limit.
+ gas_limit = opts.get("gas")
+ if gas_limit is None:
+ tx_obj = {"from": from_addr, "to": to_addr, "value": hex(int(value)) if not str(value).startswith("0x") else value}
+ if call_data != "0x":
+ tx_obj["data"] = call_data
+ gas_hex = _rpc_call("eth_estimateGas", [tx_obj], network)
+ gas_limit = int(int(gas_hex, 16) * 1.2) # 20% buffer
+ else:
+ gas_limit = int(gas_limit)
+
+ # Format value.
+ if isinstance(value, str) and value.startswith("0x"):
+ value_hex = value
+ else:
+ value_hex = hex(int(value))
+
+ # Build and sign transaction.
+ tx_req = {
+ "chain_id": chain_id,
+ "to": to_addr,
+ "nonce": nonce,
+ "gas_limit": gas_limit,
+ "max_fee_per_gas": max_fee,
+ "max_priority_fee_per_gas": max_priority,
+ "value": value_hex,
+ "data": call_data,
+ }
+
+ result = _signer_post(f"/api/v1/sign/{from_addr}/transaction", tx_req)
+ signed_tx = result.get("signed_transaction", "")
+
+ print(f"Chain: {network} (chain_id={chain_id})")
+ print(f"From: {from_addr}")
+ print(f"To: {to_addr}")
+ print(f"Value: {int(value_hex, 16)} wei")
+ print(f"Gas: {gas_limit}")
+ print(f"Max fee: {max_fee} wei")
+ print(f"Priority: {max_priority} wei")
+ print(f"Nonce: {nonce}")
+ print(f"Signed tx: {signed_tx}")
+ return signed_tx
+
+
+def cmd_send_tx(args):
+ """Sign and broadcast a transaction."""
+ signed_tx = cmd_sign_tx(args)
+ if not signed_tx:
+ sys.exit(1)
+
+ opts = _parse_tx_flags(args)
+ network = opts.get("network", NETWORK)
+
+ print(f"\nBroadcasting to {network}...")
+ tx_hash = _rpc_call("eth_sendRawTransaction", [signed_tx], network)
+ print(f"Transaction hash: {tx_hash}")
+
+ print("Waiting for receipt...")
+ import time
+ for _ in range(60):
+ receipt = _rpc_call("eth_getTransactionReceipt", [tx_hash], network)
+ if receipt is not None:
+ status = int(receipt.get("status", "0x0"), 16)
+ print(f"Status: {'success' if status == 1 else 'reverted'}")
+ print(f"Block: {int(receipt.get('blockNumber', '0x0'), 16)}")
+ print(f"Gas used: {int(receipt.get('gasUsed', '0x0'), 16)}")
+ return
+ time.sleep(2)
+ print("Timeout waiting for receipt. Transaction may still be pending.")
+
+
+def _parse_tx_flags(args):
+ """Parse --flag value pairs from argument list."""
+ opts = {}
+ i = 0
+ while i < len(args):
+ if args[i].startswith("--"):
+ key = args[i][2:].replace("-", "_")
+ if i + 1 < len(args) and not args[i + 1].startswith("--"):
+ opts[key] = args[i + 1]
+ i += 2
+ else:
+ opts[key] = True
+ i += 1
+ else:
+ i += 1
+ return opts
+
+
+def usage():
+ print("Usage: python3 scripts/signer.py [--network ] [args...]")
+ print()
+ print("Commands:")
+ print(" accounts List signing addresses")
+ print(" health Check remote-signer health")
+ print(" sign Sign a raw 32-byte hash")
+ print(" sign-msg Sign a message (EIP-191)")
+ print(" sign-tx --from --to [--value ] [--data ]")
+ print(" [--gas ] [--nonce ] [--network ]")
+ print(" Sign an EIP-1559 transaction")
+ print(" send-tx [same flags as sign-tx] Sign AND broadcast via eRPC")
+ print(" sign-typed Sign EIP-712 typed data")
+
+
+if __name__ == "__main__":
+ args = sys.argv[1:]
+
+ # Parse --network flag.
+ while args and args[0] == "--network":
+ NETWORK = args[1]
+ args = args[2:]
+
+ if not args:
+ usage()
+ sys.exit(0)
+
+ cmd = args[0]
+ args = args[1:]
+
+ if cmd == "accounts":
+ cmd_accounts()
+ elif cmd == "health":
+ cmd_health()
+ elif cmd == "sign":
+ if len(args) < 2:
+ print("Usage: sign ")
+ sys.exit(1)
+ cmd_sign(args[0], args[1])
+ elif cmd == "sign-msg":
+ if len(args) < 2:
+ print("Usage: sign-msg ")
+ sys.exit(1)
+ cmd_sign_msg(args[0], args[1])
+ elif cmd == "sign-tx":
+ if not args:
+ print("Usage: sign-tx --from --to [--value ] ...")
+ sys.exit(1)
+ cmd_sign_tx(args)
+ elif cmd == "send-tx":
+ if not args:
+ print("Usage: send-tx --from --to [--value ] ...")
+ sys.exit(1)
+ cmd_send_tx(args)
+ elif cmd == "sign-typed":
+ if len(args) < 2:
+ print("Usage: sign-typed ")
+ sys.exit(1)
+ cmd_sign_typed(args[0], args[1])
+ else:
+ print(f"Unknown command: {cmd}")
+ usage()
+ sys.exit(1)
diff --git a/internal/embed/skills/wallets/SKILL.md b/internal/embed/skills/wallets/SKILL.md
index 7e50920..90cfecc 100644
--- a/internal/embed/skills/wallets/SKILL.md
+++ b/internal/embed/skills/wallets/SKILL.md
@@ -107,7 +107,7 @@ cast send ... --private-key $DEPLOYER_PRIVATE_KEY
cast send ... --ledger
```
-**Obol Stack will use encrypted V3 keystores by default.** Transaction signing support via Web3Signer is coming soon.
+**Obol Stack uses encrypted V3 keystores by default.** Transaction signing is handled by the remote-signer service — see the `ethereum-local-wallet` skill.
**Rule of thumb:** If `grep -r "0x[a-fA-F0-9]{64}" .` matches anything in your source code, you have a problem. Same for `grep -r "g.alchemy.com/v2/[A-Za-z0-9]"` or any RPC URL with an embedded API key.
diff --git a/internal/openclaw/FOUNDRY_VERSION b/internal/openclaw/FOUNDRY_VERSION
index 0483505..336d9bd 100644
--- a/internal/openclaw/FOUNDRY_VERSION
+++ b/internal/openclaw/FOUNDRY_VERSION
@@ -1,3 +1,3 @@
# renovate: datasource=github-releases depName=foundry-rs/foundry
-# Pins the Foundry nightly version for cast/forge/anvil in the OpenClaw image.
-nightly-63bb261c14c1a83c301fde2ea7e20279c781be33
+# Pins the Foundry version for cast/forge/anvil in the OpenClaw image.
+v1.5.1
diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go
index 3d4e89c..b8beace 100644
--- a/internal/openclaw/openclaw.go
+++ b/internal/openclaw/openclaw.go
@@ -41,7 +41,11 @@ const (
userSecretsK8sSecretRef = "openclaw-user-secrets"
// chartVersion pins the openclaw Helm chart version from the obol repo.
// renovate: datasource=helm depName=openclaw registryUrl=https://obolnetwork.github.io/helm-charts/
- chartVersion = "0.1.4"
+ chartVersion = "0.1.5"
+
+ // remoteSignerChartVersion pins the remote-signer Helm chart version.
+ // renovate: datasource=helm depName=remote-signer registryUrl=https://obolnetwork.github.io/helm-charts/
+ remoteSignerChartVersion = "0.2.0"
)
// OnboardOptions contains options for the onboard command
@@ -206,7 +210,24 @@ func Onboard(cfg *config.Config, opts OnboardOptions) error {
return fmt.Errorf("failed to write overlay values: %w", err)
}
- // Generate helmfile.yaml referencing obol/openclaw from the published Helm repo
+ // Generate Ethereum signing wallet (key + remote-signer config).
+ fmt.Println("\nGenerating Ethereum wallet...")
+ wallet, err := GenerateWallet(cfg, id)
+ if err != nil {
+ os.RemoveAll(deploymentDir)
+ return fmt.Errorf("failed to generate wallet: %w", err)
+ }
+ rsValues := generateRemoteSignerValues(wallet)
+ if err := os.WriteFile(filepath.Join(deploymentDir, "values-remote-signer.yaml"), []byte(rsValues), 0600); err != nil {
+ os.RemoveAll(deploymentDir)
+ return fmt.Errorf("failed to write remote-signer values: %w", err)
+ }
+ if err := writeWalletMetadata(deploymentDir, wallet); err != nil {
+ os.RemoveAll(deploymentDir)
+ return fmt.Errorf("failed to write wallet metadata: %w", err)
+ }
+
+ // Generate helmfile.yaml referencing obol/openclaw + remote-signer
helmfileContent := generateHelmfile(id, namespace)
if err := os.WriteFile(filepath.Join(deploymentDir, "helmfile.yaml"), []byte(helmfileContent), 0644); err != nil {
os.RemoveAll(deploymentDir)
@@ -217,13 +238,18 @@ func Onboard(cfg *config.Config, opts OnboardOptions) error {
fmt.Printf(" Deployment: %s/%s\n", appName, id)
fmt.Printf(" Namespace: %s\n", namespace)
fmt.Printf(" Hostname: %s\n", hostname)
+ fmt.Printf(" Wallet: %s\n", wallet.Address)
fmt.Printf(" Location: %s\n", deploymentDir)
fmt.Printf("\nFiles created:\n")
- fmt.Printf(" - values-obol.yaml Obol Stack overlay (httpRoute, providers, eRPC)\n")
- fmt.Printf(" - helmfile.yaml Deployment configuration (chart: obol/openclaw v%s)\n", chartVersion)
+ fmt.Printf(" - values-obol.yaml Obol Stack overlay (httpRoute, providers, eRPC)\n")
+ fmt.Printf(" - values-remote-signer.yaml Remote-signer config (keystore password)\n")
+ fmt.Printf(" - wallet.json Wallet metadata (address, keystore UUID)\n")
+ fmt.Printf(" - helmfile.yaml Deployment configuration\n")
if len(secretData) > 0 {
fmt.Printf(" - %s Local secret values (used to create %s in-cluster)\n", userSecretsFileName, userSecretsK8sSecretRef)
}
+ fmt.Printf("\n Back up your signing key:\n")
+ fmt.Printf(" cp -r %s ~/obol-wallet-backup/\n", keystoreVolumePath(cfg, id))
// Stage default skills to deployment directory (immediate, no cluster needed)
fmt.Println("\nStaging default skills...")
@@ -276,6 +302,10 @@ func doSync(cfg *config.Config, id string) error {
return fmt.Errorf("failed to sync OpenClaw user secrets: %w", err)
}
+ // Ensure wallet keystore + remote-signer values exist (handles
+ // deployments created before wallet was added, or manual re-syncs).
+ ensureWallet(cfg, id, deploymentDir)
+
// Stage default skills and inject directly to the host-side PVC path.
// The local-path-provisioner creates the PV directory on the host at a
// predictable path ($DATA_DIR/openclaw-/openclaw-data/), so we can
@@ -301,6 +331,9 @@ func doSync(cfg *config.Config, id string) error {
return fmt.Errorf("helmfile sync failed: %w", err)
}
+ // Apply wallet-metadata ConfigMap (namespace now exists after helmfile sync).
+ applyWalletMetadataConfigMap(cfg, id, deploymentDir)
+
hostname := fmt.Sprintf("openclaw-%s.%s", id, defaultDomain)
fmt.Printf("\n✓ OpenClaw installed successfully!\n")
@@ -1279,6 +1312,12 @@ models:
erpc:
url: http://erpc.erpc.svc.cluster.local:4000/rpc
+# Remote-signer wallet for Ethereum transaction signing.
+# The remote-signer runs in the same namespace as OpenClaw.
+extraEnv:
+ - name: REMOTE_SIGNER_URL
+ value: http://remote-signer:9000
+
# Skills: injected directly to the host-side PVC path at
# $DATA_DIR/openclaw-/openclaw-data/.openclaw/skills/
# OpenClaw's file watcher picks them up; no ConfigMap needed.
@@ -1732,7 +1771,8 @@ func collectSensitiveData(imported *ImportResult) map[string]string {
return secretData
}
-// generateHelmfile creates a helmfile.yaml referencing the published obol/openclaw chart.
+// generateHelmfile creates a helmfile.yaml referencing the published
+// obol/openclaw and obol/remote-signer charts in the same namespace.
func generateHelmfile(id, namespace string) string {
return fmt.Sprintf(`# OpenClaw instance: %s
# Managed by obol openclaw
@@ -1749,5 +1789,12 @@ releases:
version: %s
values:
- values-obol.yaml
-`, id, namespace, chartVersion)
+
+ - name: remote-signer
+ namespace: %s
+ chart: obol/remote-signer
+ version: %s
+ values:
+ - values-remote-signer.yaml
+`, id, namespace, chartVersion, namespace, remoteSignerChartVersion)
}
diff --git a/internal/openclaw/skills_injection_test.go b/internal/openclaw/skills_injection_test.go
index 9df6780..7bd5076 100644
--- a/internal/openclaw/skills_injection_test.go
+++ b/internal/openclaw/skills_injection_test.go
@@ -29,7 +29,7 @@ func TestStageDefaultSkills(t *testing.T) {
}
// Verify all expected skills were staged
- for _, skill := range []string{"distributed-validators", "ethereum-networks", "obol-stack", "addresses", "wallets"} {
+ for _, skill := range []string{"distributed-validators", "ethereum-networks", "ethereum-local-wallet", "obol-stack", "addresses", "wallets"} {
skillMD := filepath.Join(skillsDir, skill, "SKILL.md")
if _, err := os.Stat(skillMD); err != nil {
t.Errorf("%s/SKILL.md not staged: %v", skill, err)
@@ -77,7 +77,7 @@ func TestInjectSkillsToVolume(t *testing.T) {
// Verify skills landed in the volume path
volumePath := skillsVolumePath(cfg, "test-inject")
- for _, skill := range []string{"distributed-validators", "ethereum-networks", "obol-stack", "addresses", "wallets"} {
+ for _, skill := range []string{"distributed-validators", "ethereum-networks", "ethereum-local-wallet", "obol-stack", "addresses", "wallets"} {
skillMD := filepath.Join(volumePath, skill, "SKILL.md")
if _, err := os.Stat(skillMD); err != nil {
t.Errorf("%s/SKILL.md not injected to volume: %v", skill, err)
@@ -87,6 +87,7 @@ func TestInjectSkillsToVolume(t *testing.T) {
// Verify scripts and references are also injected
for _, sub := range []string{
"ethereum-networks/scripts/rpc.py",
+ "ethereum-local-wallet/scripts/signer.py",
"obol-stack/scripts/kube.py",
"distributed-validators/references/api-examples.md",
} {
diff --git a/internal/openclaw/wallet.go b/internal/openclaw/wallet.go
new file mode 100644
index 0000000..9d0fa1c
--- /dev/null
+++ b/internal/openclaw/wallet.go
@@ -0,0 +1,485 @@
+package openclaw
+
+import (
+ "bytes"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "math/big"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/ObolNetwork/obol-stack/internal/config"
+ secp256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4"
+ "github.com/google/uuid"
+ "golang.org/x/crypto/scrypt"
+ "golang.org/x/crypto/sha3"
+)
+
+// WalletInfo holds generated wallet metadata returned from GenerateWallet.
+type WalletInfo struct {
+ Address string `json:"address"` // 0x-prefixed Ethereum address
+ PublicKey string `json:"publicKey"` // 0x-prefixed uncompressed public key (130 hex chars)
+ KeystoreUUID string `json:"keystore_uuid"` // UUID of the V3 keystore file
+ KeystorePath string `json:"keystore_path"` // Absolute host path to keystore JSON
+ CreatedAt string `json:"createdAt"` // ISO 8601 timestamp
+ Password string `json:"-"` // Keystore password (not serialized)
+}
+
+// v3 keystore types matching Web3 Secret Storage Definition v3.
+type v3Keystore struct {
+ Address string `json:"address"` // hex address without 0x prefix
+ Crypto v3Crypto `json:"crypto"`
+ ID string `json:"id"`
+ Version int `json:"version"`
+}
+
+type v3Crypto struct {
+ Cipher string `json:"cipher"`
+ CipherText string `json:"ciphertext"`
+ CipherParams cipherParams `json:"cipherparams"`
+ KDF string `json:"kdf"`
+ KDFParams kdfParams `json:"kdfparams"`
+ MAC string `json:"mac"`
+}
+
+type cipherParams struct {
+ IV string `json:"iv"`
+}
+
+type kdfParams struct {
+ DKLen int `json:"dklen"`
+ N int `json:"n"`
+ R int `json:"r"`
+ P int `json:"p"`
+ Salt string `json:"salt"`
+}
+
+// scrypt parameters matching go-ethereum defaults.
+const (
+ scryptN = 262144
+ scryptR = 8
+ scryptP = 1
+ scryptDKLen = 32
+)
+
+// GenerateWallet creates a new secp256k1 signing key, encrypts it as a V3
+// keystore, and provisions it to the host-side PVC path for the remote-signer.
+func GenerateWallet(cfg *config.Config, id string) (*WalletInfo, error) {
+ privKey, pubKey, err := generateKeypair()
+ if err != nil {
+ return nil, fmt.Errorf("key generation failed: %w", err)
+ }
+
+ address := addressFromPublicKey(pubKey)
+
+ password, err := generateRandomPassword(32)
+ if err != nil {
+ return nil, fmt.Errorf("password generation failed: %w", err)
+ }
+
+ keystoreJSON, keystoreID, err := encryptToV3Keystore(privKey, pubKey, password)
+ if err != nil {
+ return nil, fmt.Errorf("keystore encryption failed: %w", err)
+ }
+
+ keystorePath, err := provisionKeystoreToVolume(cfg, id, keystoreID, keystoreJSON)
+ if err != nil {
+ return nil, fmt.Errorf("keystore provisioning failed: %w", err)
+ }
+
+ // Uncompressed public key with 04 prefix for the frontend.
+ pubKeyHex := "0x04" + hex.EncodeToString(pubKey)
+
+ return &WalletInfo{
+ Address: address,
+ PublicKey: pubKeyHex,
+ KeystoreUUID: keystoreID,
+ KeystorePath: keystorePath,
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ Password: password,
+ }, nil
+}
+
+// generateKeypair creates a random secp256k1 private key using crypto/rand.
+// Returns the 32-byte private key and 64-byte uncompressed public key (without 0x04 prefix).
+func generateKeypair() (privKeyBytes []byte, pubKeyUncompressed []byte, err error) {
+ privKey, err := secp256k1.GeneratePrivateKey()
+ if err != nil {
+ return nil, nil, fmt.Errorf("secp256k1 key generation: %w", err)
+ }
+
+ privKeyBytes = privKey.Serialize() // 32 bytes
+
+ // Uncompressed public key: 04 || X || Y (65 bytes).
+ // For Ethereum address derivation we need X || Y (64 bytes, no prefix).
+ pubKeyUncompressed = privKey.PubKey().SerializeUncompressed()[1:]
+
+ return privKeyBytes, pubKeyUncompressed, nil
+}
+
+// addressFromPublicKey computes the Ethereum address from a 64-byte
+// uncompressed public key (without the 0x04 prefix).
+// Returns the EIP-55 checksummed address with 0x prefix.
+func addressFromPublicKey(pubKey []byte) string {
+ h := sha3.NewLegacyKeccak256()
+ h.Write(pubKey)
+ hash := h.Sum(nil)
+ rawAddr := hex.EncodeToString(hash[12:]) // last 20 bytes
+
+ return toChecksumAddress(rawAddr)
+}
+
+// toChecksumAddress applies EIP-55 mixed-case checksum encoding.
+func toChecksumAddress(addr string) string {
+ addr = strings.ToLower(addr)
+ h := sha3.NewLegacyKeccak256()
+ h.Write([]byte(addr))
+ hash := hex.EncodeToString(h.Sum(nil))
+
+ var result strings.Builder
+ result.WriteString("0x")
+ for i, c := range addr {
+ if c >= '0' && c <= '9' {
+ result.WriteRune(c)
+ } else {
+ // If the corresponding hex digit in the hash is >= 8, uppercase it.
+ nibble := hash[i]
+ if nibble >= '8' {
+ result.WriteRune(c - 32) // lowercase to uppercase
+ } else {
+ result.WriteRune(c)
+ }
+ }
+ }
+ return result.String()
+}
+
+// encryptToV3Keystore encrypts a private key using the Web3 Secret Storage v3
+// format: scrypt KDF (N=262144, r=8, p=1) + AES-128-CTR.
+// Returns the JSON-encoded keystore and the keystore UUID.
+func encryptToV3Keystore(privKey, pubKey []byte, password string) ([]byte, string, error) {
+ // Generate random salt (32 bytes) and IV (16 bytes).
+ salt := make([]byte, 32)
+ if _, err := rand.Read(salt); err != nil {
+ return nil, "", fmt.Errorf("salt generation: %w", err)
+ }
+ iv := make([]byte, 16)
+ if _, err := rand.Read(iv); err != nil {
+ return nil, "", fmt.Errorf("iv generation: %w", err)
+ }
+
+ // Derive key via scrypt.
+ derivedKey, err := scrypt.Key([]byte(password), salt, scryptN, scryptR, scryptP, scryptDKLen)
+ if err != nil {
+ return nil, "", fmt.Errorf("scrypt key derivation: %w", err)
+ }
+
+ // Encrypt private key with AES-128-CTR (first 16 bytes of derived key).
+ block, err := aes.NewCipher(derivedKey[:16])
+ if err != nil {
+ return nil, "", fmt.Errorf("aes cipher: %w", err)
+ }
+ cipherText := make([]byte, len(privKey))
+ stream := cipher.NewCTR(block, iv)
+ stream.XORKeyStream(cipherText, privKey)
+
+ // MAC = Keccak-256(derivedKey[16:32] || cipherText).
+ mac := sha3.NewLegacyKeccak256()
+ mac.Write(derivedKey[16:32])
+ mac.Write(cipherText)
+ macHash := mac.Sum(nil)
+
+ // Derive address from public key (without 0x prefix, lowercase).
+ address := addressFromPublicKey(pubKey)
+ address = strings.TrimPrefix(strings.ToLower(address), "0x")
+
+ keystoreID := uuid.New().String()
+
+ ks := v3Keystore{
+ Address: address,
+ Crypto: v3Crypto{
+ Cipher: "aes-128-ctr",
+ CipherText: hex.EncodeToString(cipherText),
+ CipherParams: cipherParams{
+ IV: hex.EncodeToString(iv),
+ },
+ KDF: "scrypt",
+ KDFParams: kdfParams{
+ DKLen: scryptDKLen,
+ N: scryptN,
+ R: scryptR,
+ P: scryptP,
+ Salt: hex.EncodeToString(salt),
+ },
+ MAC: hex.EncodeToString(macHash),
+ },
+ ID: keystoreID,
+ Version: 3,
+ }
+
+ data, err := json.MarshalIndent(ks, "", " ")
+ if err != nil {
+ return nil, "", fmt.Errorf("json marshal: %w", err)
+ }
+
+ return data, keystoreID, nil
+}
+
+// decryptV3Keystore decrypts a V3 keystore JSON to recover the private key.
+// Used for testing round-trip correctness.
+func decryptV3Keystore(keystoreJSON []byte, password string) ([]byte, error) {
+ var ks v3Keystore
+ if err := json.Unmarshal(keystoreJSON, &ks); err != nil {
+ return nil, fmt.Errorf("json unmarshal: %w", err)
+ }
+
+ salt, err := hex.DecodeString(ks.Crypto.KDFParams.Salt)
+ if err != nil {
+ return nil, fmt.Errorf("decode salt: %w", err)
+ }
+ iv, err := hex.DecodeString(ks.Crypto.CipherParams.IV)
+ if err != nil {
+ return nil, fmt.Errorf("decode iv: %w", err)
+ }
+ cipherText, err := hex.DecodeString(ks.Crypto.CipherText)
+ if err != nil {
+ return nil, fmt.Errorf("decode ciphertext: %w", err)
+ }
+ storedMAC, err := hex.DecodeString(ks.Crypto.MAC)
+ if err != nil {
+ return nil, fmt.Errorf("decode mac: %w", err)
+ }
+
+ derivedKey, err := scrypt.Key([]byte(password), salt, ks.Crypto.KDFParams.N, ks.Crypto.KDFParams.R, ks.Crypto.KDFParams.P, ks.Crypto.KDFParams.DKLen)
+ if err != nil {
+ return nil, fmt.Errorf("scrypt: %w", err)
+ }
+
+ // Verify MAC.
+ mac := sha3.NewLegacyKeccak256()
+ mac.Write(derivedKey[16:32])
+ mac.Write(cipherText)
+ computedMAC := mac.Sum(nil)
+ if !hmacEqual(computedMAC, storedMAC) {
+ return nil, fmt.Errorf("MAC mismatch: wrong password or corrupted keystore")
+ }
+
+ // Decrypt.
+ block, err := aes.NewCipher(derivedKey[:16])
+ if err != nil {
+ return nil, fmt.Errorf("aes cipher: %w", err)
+ }
+ plaintext := make([]byte, len(cipherText))
+ stream := cipher.NewCTR(block, iv)
+ stream.XORKeyStream(plaintext, cipherText)
+
+ return plaintext, nil
+}
+
+// hmacEqual compares two byte slices in constant time.
+func hmacEqual(a, b []byte) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ var result byte
+ for i := range a {
+ result |= a[i] ^ b[i]
+ }
+ return result == 0
+}
+
+// generateRandomPassword creates a cryptographically random password using
+// alphanumeric characters (a-z, A-Z, 0-9).
+func generateRandomPassword(length int) (string, error) {
+ const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ charsetLen := big.NewInt(int64(len(charset)))
+
+ result := make([]byte, length)
+ for i := range result {
+ n, err := rand.Int(rand.Reader, charsetLen)
+ if err != nil {
+ return "", fmt.Errorf("random int: %w", err)
+ }
+ result[i] = charset[n.Int64()]
+ }
+ return string(result), nil
+}
+
+// keystoreVolumePath returns the host-side path where the remote-signer's
+// PVC stores keystores. This follows the local-path-provisioner pattern:
+// $DATA_DIR///
+func keystoreVolumePath(cfg *config.Config, id string) string {
+ namespace := fmt.Sprintf("%s-%s", appName, id)
+ return filepath.Join(cfg.DataDir, namespace, "remote-signer-keystores")
+}
+
+// provisionKeystoreToVolume writes the V3 keystore JSON to the host-side PVC
+// path before the remote-signer pod starts. Returns the absolute path to the
+// written keystore file.
+func provisionKeystoreToVolume(cfg *config.Config, id, keystoreID string, keystoreJSON []byte) (string, error) {
+ dir := keystoreVolumePath(cfg, id)
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return "", fmt.Errorf("create keystore directory: %w", err)
+ }
+
+ filename := keystoreID + ".json"
+ path := filepath.Join(dir, filename)
+ if err := os.WriteFile(path, keystoreJSON, 0600); err != nil {
+ return "", fmt.Errorf("write keystore: %w", err)
+ }
+
+ return path, nil
+}
+
+// generateRemoteSignerValues emits the values-remote-signer.yaml content
+// for the remote-signer Helm release.
+func generateRemoteSignerValues(wallet *WalletInfo) string {
+ return fmt.Sprintf(`# Remote-signer configuration
+# Managed by obol openclaw — do not edit manually.
+
+keystorePassword:
+ value: %q
+
+persistence:
+ enabled: true
+ size: 100Mi
+`, wallet.Password)
+}
+
+// walletMetadataPath returns the path to the wallet.json metadata file
+// in the deployment directory.
+func walletMetadataPath(deploymentDir string) string {
+ return filepath.Join(deploymentDir, "wallet.json")
+}
+
+// writeWalletMetadata writes the wallet address and UUID to a JSON file
+// in the deployment directory for re-sync and display purposes.
+func writeWalletMetadata(deploymentDir string, wallet *WalletInfo) error {
+ data, err := json.MarshalIndent(wallet, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal wallet metadata: %w", err)
+ }
+ return os.WriteFile(walletMetadataPath(deploymentDir), data, 0644)
+}
+
+// readWalletMetadata reads existing wallet metadata from the deployment directory.
+func readWalletMetadata(deploymentDir string) (*WalletInfo, error) {
+ data, err := os.ReadFile(walletMetadataPath(deploymentDir))
+ if err != nil {
+ return nil, err
+ }
+ var wallet WalletInfo
+ if err := json.Unmarshal(data, &wallet); err != nil {
+ return nil, fmt.Errorf("unmarshal wallet metadata: %w", err)
+ }
+ return &wallet, nil
+}
+
+// ensureWallet checks if wallet files exist for a deployment. If not
+// (e.g., a pre-wallet deployment), it generates and provisions them.
+// This is called during doSync to handle upgrades gracefully.
+func ensureWallet(cfg *config.Config, id, deploymentDir string) {
+ // Check if wallet metadata already exists.
+ if _, err := os.Stat(walletMetadataPath(deploymentDir)); err == nil {
+ return // wallet already provisioned
+ }
+
+ // Check if values-remote-signer.yaml exists (written during onboard).
+ valuesPath := filepath.Join(deploymentDir, "values-remote-signer.yaml")
+ if _, err := os.Stat(valuesPath); err == nil {
+ return // values exist, wallet was provisioned
+ }
+
+ // No wallet yet — generate one.
+ fmt.Println("Generating Ethereum wallet for this instance...")
+ wallet, err := GenerateWallet(cfg, id)
+ if err != nil {
+ fmt.Printf("Warning: could not generate wallet: %v\n", err)
+ return
+ }
+
+ values := generateRemoteSignerValues(wallet)
+ if err := os.WriteFile(valuesPath, []byte(values), 0644); err != nil {
+ fmt.Printf("Warning: could not write remote-signer values: %v\n", err)
+ return
+ }
+
+ if err := writeWalletMetadata(deploymentDir, wallet); err != nil {
+ fmt.Printf("Warning: could not write wallet metadata: %v\n", err)
+ return
+ }
+
+ fmt.Printf(" Wallet address: %s\n", wallet.Address)
+}
+
+// applyWalletMetadataConfigMap creates or updates a wallet-metadata ConfigMap
+// in the instance namespace. The frontend reads this to display wallet addresses.
+// Must be called after helmfile sync (namespace must exist).
+func applyWalletMetadataConfigMap(cfg *config.Config, id, deploymentDir string) {
+ wallet, err := readWalletMetadata(deploymentDir)
+ if err != nil {
+ return // no wallet metadata, nothing to apply
+ }
+
+ namespace := fmt.Sprintf("%s-%s", appName, id)
+ kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml")
+ kubectlBinary := filepath.Join(cfg.BinDir, "kubectl")
+
+ // Build addresses.json matching the frontend's WalletMetadata type.
+ addressesJSON := map[string]interface{}{
+ "instanceId": id,
+ "addresses": []map[string]string{
+ {
+ "address": wallet.Address,
+ "publicKey": wallet.PublicKey,
+ "createdAt": wallet.CreatedAt,
+ "label": fmt.Sprintf("obol-agent-%s", id),
+ },
+ },
+ "count": 1,
+ }
+
+ addressesData, err := json.Marshal(addressesJSON)
+ if err != nil {
+ fmt.Printf("Warning: could not marshal wallet metadata: %v\n", err)
+ return
+ }
+
+ manifest := map[string]interface{}{
+ "apiVersion": "v1",
+ "kind": "ConfigMap",
+ "metadata": map[string]interface{}{
+ "name": "wallet-metadata",
+ "namespace": namespace,
+ "labels": map[string]string{
+ "app.kubernetes.io/component": "remote-signer",
+ "app.kubernetes.io/managed-by": "obol",
+ },
+ },
+ "data": map[string]string{
+ "addresses.json": string(addressesData),
+ },
+ }
+
+ raw, err := json.Marshal(manifest)
+ if err != nil {
+ fmt.Printf("Warning: could not marshal ConfigMap: %v\n", err)
+ return
+ }
+
+ cmd := exec.Command(kubectlBinary, "apply", "-f", "-")
+ cmd.Env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeconfigPath))
+ cmd.Stdin = bytes.NewReader(raw)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ fmt.Printf("Warning: could not apply wallet-metadata ConfigMap: %v\n%s", err, stderr.String())
+ }
+}
diff --git a/internal/openclaw/wallet_test.go b/internal/openclaw/wallet_test.go
new file mode 100644
index 0000000..62d3caa
--- /dev/null
+++ b/internal/openclaw/wallet_test.go
@@ -0,0 +1,309 @@
+package openclaw
+
+import (
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/ObolNetwork/obol-stack/internal/config"
+)
+
+func TestGenerateKeypair(t *testing.T) {
+ privKey, pubKey, err := generateKeypair()
+ if err != nil {
+ t.Fatalf("generateKeypair: %v", err)
+ }
+
+ if len(privKey) != 32 {
+ t.Errorf("private key length = %d, want 32", len(privKey))
+ }
+ if len(pubKey) != 64 {
+ t.Errorf("public key length = %d, want 64 (uncompressed without prefix)", len(pubKey))
+ }
+
+ // Keys should be non-zero.
+ allZero := true
+ for _, b := range privKey {
+ if b != 0 {
+ allZero = false
+ break
+ }
+ }
+ if allZero {
+ t.Error("private key is all zeros")
+ }
+}
+
+func TestGenerateKeypairUniqueness(t *testing.T) {
+ priv1, _, err := generateKeypair()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv2, _, err := generateKeypair()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if hex.EncodeToString(priv1) == hex.EncodeToString(priv2) {
+ t.Error("two generated keys are identical")
+ }
+}
+
+func TestAddressFromPublicKey(t *testing.T) {
+ // Known test vector: private key 0x01 on secp256k1.
+ // Public key (uncompressed, no prefix): well-known value.
+ // We test that the output is a valid 0x-prefixed 42-char hex string.
+ _, pubKey, err := generateKeypair()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ addr := addressFromPublicKey(pubKey)
+ if !strings.HasPrefix(addr, "0x") {
+ t.Errorf("address should start with 0x, got %s", addr)
+ }
+ if len(addr) != 42 {
+ t.Errorf("address length = %d, want 42", len(addr))
+ }
+
+ // Verify it's valid hex (after removing 0x).
+ _, err = hex.DecodeString(strings.ToLower(addr[2:]))
+ if err != nil {
+ t.Errorf("address is not valid hex: %v", err)
+ }
+}
+
+func TestToChecksumAddress(t *testing.T) {
+ // EIP-55 test vectors.
+ tests := []struct {
+ input string
+ want string
+ }{
+ {"5aaeb6053f3e94c9b9a09f33669435e7ef1beaed", "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"},
+ {"fb6916095ca1df60bb79ce92ce3ea74c37c5d359", "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359"},
+ }
+ for _, tt := range tests {
+ got := toChecksumAddress(tt.input)
+ if got != tt.want {
+ t.Errorf("toChecksumAddress(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ }
+}
+
+func TestEncryptToV3Keystore(t *testing.T) {
+ privKey, pubKey, err := generateKeypair()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ password := "test-password-123"
+ keystoreJSON, keystoreID, err := encryptToV3Keystore(privKey, pubKey, password)
+ if err != nil {
+ t.Fatalf("encryptToV3Keystore: %v", err)
+ }
+
+ if len(keystoreID) == 0 {
+ t.Error("keystore ID is empty")
+ }
+
+ // Parse and validate JSON structure.
+ var ks v3Keystore
+ if err := json.Unmarshal(keystoreJSON, &ks); err != nil {
+ t.Fatalf("unmarshal keystore: %v", err)
+ }
+
+ if ks.Version != 3 {
+ t.Errorf("version = %d, want 3", ks.Version)
+ }
+ if ks.Crypto.Cipher != "aes-128-ctr" {
+ t.Errorf("cipher = %q, want aes-128-ctr", ks.Crypto.Cipher)
+ }
+ if ks.Crypto.KDF != "scrypt" {
+ t.Errorf("kdf = %q, want scrypt", ks.Crypto.KDF)
+ }
+ if ks.Crypto.KDFParams.N != 262144 {
+ t.Errorf("scrypt N = %d, want 262144", ks.Crypto.KDFParams.N)
+ }
+ if ks.Crypto.KDFParams.R != 8 {
+ t.Errorf("scrypt r = %d, want 8", ks.Crypto.KDFParams.R)
+ }
+ if ks.Crypto.KDFParams.P != 1 {
+ t.Errorf("scrypt p = %d, want 1", ks.Crypto.KDFParams.P)
+ }
+ if ks.Crypto.KDFParams.DKLen != 32 {
+ t.Errorf("scrypt dklen = %d, want 32", ks.Crypto.KDFParams.DKLen)
+ }
+ if len(ks.Address) != 40 {
+ t.Errorf("address length = %d, want 40 (hex without 0x)", len(ks.Address))
+ }
+ if ks.ID != keystoreID {
+ t.Errorf("ID = %q, want %q", ks.ID, keystoreID)
+ }
+}
+
+func TestEncryptDecryptRoundTrip(t *testing.T) {
+ privKey, pubKey, err := generateKeypair()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ password := "round-trip-test-password"
+ keystoreJSON, _, err := encryptToV3Keystore(privKey, pubKey, password)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Decrypt and verify.
+ recovered, err := decryptV3Keystore(keystoreJSON, password)
+ if err != nil {
+ t.Fatalf("decrypt: %v", err)
+ }
+
+ if hex.EncodeToString(recovered) != hex.EncodeToString(privKey) {
+ t.Errorf("recovered key does not match original")
+ }
+}
+
+func TestDecryptWrongPassword(t *testing.T) {
+ privKey, pubKey, err := generateKeypair()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ keystoreJSON, _, err := encryptToV3Keystore(privKey, pubKey, "correct-password")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = decryptV3Keystore(keystoreJSON, "wrong-password")
+ if err == nil {
+ t.Error("expected error when decrypting with wrong password")
+ }
+ if !strings.Contains(err.Error(), "MAC mismatch") {
+ t.Errorf("expected MAC mismatch error, got: %v", err)
+ }
+}
+
+func TestGenerateRandomPassword(t *testing.T) {
+ p1, err := generateRandomPassword(32)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(p1) != 32 {
+ t.Errorf("password length = %d, want 32", len(p1))
+ }
+
+ // Verify charset (alphanumeric only).
+ for _, c := range p1 {
+ if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
+ t.Errorf("password contains non-alphanumeric character: %c", c)
+ }
+ }
+
+ // Two passwords should be different.
+ p2, err := generateRandomPassword(32)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p1 == p2 {
+ t.Error("two generated passwords are identical")
+ }
+}
+
+func TestKeystoreVolumePath(t *testing.T) {
+ cfg := &config.Config{
+ DataDir: "/test/data",
+ }
+ path := keystoreVolumePath(cfg, "my-agent")
+ want := "/test/data/openclaw-my-agent/remote-signer-keystores"
+ if path != want {
+ t.Errorf("keystoreVolumePath = %q, want %q", path, want)
+ }
+}
+
+func TestProvisionKeystoreToVolume(t *testing.T) {
+ tmpDir := t.TempDir()
+ cfg := &config.Config{DataDir: tmpDir}
+
+ keystoreJSON := []byte(`{"version": 3, "test": true}`)
+ path, err := provisionKeystoreToVolume(cfg, "test-id", "my-uuid", keystoreJSON)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Verify file exists.
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read keystore: %v", err)
+ }
+ if string(data) != string(keystoreJSON) {
+ t.Error("keystore content mismatch")
+ }
+
+ // Verify path structure.
+ wantPath := filepath.Join(tmpDir, "openclaw-test-id", "remote-signer-keystores", "my-uuid.json")
+ if path != wantPath {
+ t.Errorf("keystore path = %q, want %q", path, wantPath)
+ }
+
+ // Verify restrictive permissions on directory.
+ info, err := os.Stat(filepath.Dir(path))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm() != 0700 {
+ t.Errorf("keystore dir permissions = %o, want 0700", info.Mode().Perm())
+ }
+}
+
+func TestGenerateRemoteSignerValues(t *testing.T) {
+ wallet := &WalletInfo{
+ Address: "0x1234567890abcdef1234567890abcdef12345678",
+ KeystoreUUID: "test-uuid",
+ Password: "my-secret-password",
+ }
+
+ values := generateRemoteSignerValues(wallet)
+
+ if !strings.Contains(values, `keystorePassword:`) {
+ t.Error("values should contain keystorePassword section")
+ }
+ if !strings.Contains(values, `value: "my-secret-password"`) {
+ t.Error("values should contain password value")
+ }
+ if !strings.Contains(values, "persistence:") {
+ t.Error("values should contain persistence section")
+ }
+}
+
+func TestWalletMetadataRoundTrip(t *testing.T) {
+ tmpDir := t.TempDir()
+ wallet := &WalletInfo{
+ Address: "0xAbCd1234567890abcdef1234567890abcdef1234",
+ KeystoreUUID: "test-uuid-123",
+ KeystorePath: "/data/keystores/test.json",
+ Password: "should-not-serialize",
+ }
+
+ if err := writeWalletMetadata(tmpDir, wallet); err != nil {
+ t.Fatal(err)
+ }
+
+ recovered, err := readWalletMetadata(tmpDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if recovered.Address != wallet.Address {
+ t.Errorf("address = %q, want %q", recovered.Address, wallet.Address)
+ }
+ if recovered.KeystoreUUID != wallet.KeystoreUUID {
+ t.Errorf("UUID = %q, want %q", recovered.KeystoreUUID, wallet.KeystoreUUID)
+ }
+ // Password should NOT be in the serialized metadata.
+ if recovered.Password != "" {
+ t.Error("password should not be serialized in metadata")
+ }
+}
diff --git a/obolup.sh b/obolup.sh
index ebab86b..6ad8cdb 100755
--- a/obolup.sh
+++ b/obolup.sh
@@ -55,6 +55,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.2.23"
# Repository URL for building from source
readonly OBOL_REPO_URL="git@github.com:ObolNetwork/obol-stack.git"
@@ -1013,34 +1014,43 @@ install_k9s() {
# It's distributed as an npm package, so we install it locally into
# OBOL_BIN_DIR using npm --prefix to keep it workspace-contained.
install_openclaw() {
+ local current_version=""
+ local target_version="$OPENCLAW_VERSION"
+
# Remove broken symlink if exists
remove_broken_symlink "openclaw"
# Check for global openclaw first (same pattern as kubectl, helm, etc.)
local global_openclaw
if global_openclaw=$(check_global_binary "openclaw"); then
- if create_binary_symlink "openclaw" "$global_openclaw"; then
- log_success "openclaw already installed at: $global_openclaw (symlinked)"
- else
- log_success "openclaw already installed at: $global_openclaw"
+ local global_version
+ global_version=$("$global_openclaw" --version 2>/dev/null | tr -d '[:space:]' || echo "")
+ if [[ -n "$global_version" ]] && version_ge "$global_version" "$target_version"; then
+ if create_binary_symlink "openclaw" "$global_openclaw"; then
+ log_success "openclaw v$global_version already installed at: $global_openclaw (symlinked)"
+ else
+ log_success "openclaw v$global_version already installed at: $global_openclaw"
+ fi
+ return 0
fi
- return 0
fi
- # Check if already in OBOL_BIN_DIR
+ # Check current version in OBOL_BIN_DIR
if [[ -f "$OBOL_BIN_DIR/openclaw" ]]; then
- log_success "openclaw already installed"
- return 0
+ current_version=$("$OBOL_BIN_DIR/openclaw" --version 2>/dev/null | tr -d '[:space:]' || echo "")
fi
- log_info "Installing openclaw CLI..."
+ if [[ -n "$current_version" ]] && version_ge "$current_version" "$target_version"; then
+ log_success "openclaw v$current_version is up to date"
+ return 0
+ fi
# Require Node.js 22+ and npm
if ! command_exists npm; then
log_warn "npm not found — cannot install openclaw CLI"
echo ""
echo " Install Node.js 22+ first, then re-run obolup.sh"
- echo " Or install manually: npm install -g openclaw"
+ echo " Or install manually: npm install -g openclaw@$target_version"
echo ""
return 1
fi
@@ -1051,18 +1061,23 @@ install_openclaw() {
log_warn "Node.js 22+ required for openclaw (found: v${node_major:-none})"
echo ""
echo " Upgrade Node.js, then re-run obolup.sh"
- echo " Or install manually: npm install -g openclaw"
+ echo " Or install manually: npm install -g openclaw@$target_version"
echo ""
return 1
fi
+ if [[ -n "$current_version" ]]; then
+ log_info "Upgrading openclaw from v$current_version to v$target_version..."
+ else
+ log_info "Installing openclaw v$target_version..."
+ fi
+
# Install into OBOL_BIN_DIR using npm --prefix so the package lives
# alongside the other managed binaries (works for both production
# ~/.local/bin and development .workspace/bin layouts).
local npm_prefix="$OBOL_BIN_DIR/.openclaw-npm"
- log_info "Installing openclaw via npm into $OBOL_BIN_DIR..."
- if npm install --prefix "$npm_prefix" openclaw 2>&1; then
+ if npm install --prefix "$npm_prefix" "openclaw@$target_version" 2>&1; then
# Create a wrapper script in OBOL_BIN_DIR that invokes the local install.
# npm --prefix puts the .bin stubs in node_modules/.bin/ which handle
# the correct entry point (openclaw.mjs) automatically.
@@ -1071,13 +1086,13 @@ install_openclaw() {
exec "$npm_prefix/node_modules/.bin/openclaw" "\$@"
WRAPPER
chmod +x "$OBOL_BIN_DIR/openclaw"
- log_success "openclaw installed at $OBOL_BIN_DIR/openclaw"
+ log_success "openclaw v$target_version installed"
return 0
fi
log_warn "Failed to install openclaw CLI"
echo ""
- echo " Install manually: npm install -g openclaw"
+ echo " Install manually: npm install -g openclaw@$target_version"
echo ""
return 1
}
diff --git a/plans/agent-services.md b/plans/agent-services.md
new file mode 100644
index 0000000..a05869f
--- /dev/null
+++ b/plans/agent-services.md
@@ -0,0 +1,567 @@
+# Agent Services: Autonomous x402-Gated HTTP Endpoints
+
+**Goal:** A skill that lets OpenClaw deploy its own HTTP services into the cluster, gate them with x402 payments, register them with ERC-8004, expose them to the public internet, and monitor earnings — turning the agent from a tool-user into an autonomous economic actor.
+
+---
+
+## Why This Is The One
+
+The Obol Stack already has every piece:
+
+| Capability | How it exists today |
+|------------|-------------------|
+| Wallet | Web3Signer in-cluster, `signer.py` for signing |
+| Onchain identity | `agent-identity` skill, ERC-8004 registration |
+| Kubernetes cluster | k3d with Traefik gateway |
+| Public internet access | Cloudflare tunnel (`obol tunnel`) |
+| x402 payment infrastructure | `inference-gateway` binary, Go x402 SDK, Coinbase facilitator |
+| Blockchain nodes | eRPC gateway routing to local/remote nodes |
+
+What's missing: **the agent can't deploy a service, price it, and collect payment.** This skill closes that gap.
+
+---
+
+## Existing Precedent: The Inference Gateway
+
+The `inference` network (`internal/embed/networks/inference/`) already implements this exact pattern:
+
+1. User specifies a model, price, wallet, and chain
+2. Helmfile deploys: Ollama pod + x402 gateway pod + Service + HTTPRoute + metadata ConfigMap
+3. Gateway wraps Ollama's OpenAI-compatible API with x402 payment verification
+4. Traefik routes `/inference-/v1/*` to the gateway
+5. Cloudflare tunnel makes it publicly accessible
+6. Frontend discovers it via the metadata ConfigMap
+
+**The `agent-services` skill generalises this pattern** from "inference only" to "any HTTP handler the agent writes."
+
+---
+
+## Architecture
+
+```
+OpenClaw pod (writes handler + config)
+ │
+ │ 1. Agent writes handler.py (business logic)
+ │ 2. identity.sh registers with ERC-8004
+ │ 3. service.sh deploys via helmfile
+ │
+ ▼
+agent-service- namespace
+ ┌─────────────────────────────┐
+ │ Pod: agent-svc- │
+ │ ┌────────────────────────┐ │
+ │ │ x402-proxy (sidecar) │ │ ← Verifies payment, settles via facilitator
+ │ │ port 8402 │ │
+ │ └──────────┬─────────────┘ │
+ │ │ proxy_pass │
+ │ ┌──────────▼─────────────┐ │
+ │ │ handler.py (main) │ │ ← Agent's business logic (plain HTTP)
+ │ │ port 8080 │ │
+ │ └────────────────────────┘ │
+ │ │
+ │ ConfigMap: handler-code │ ← Agent's Python handler
+ │ ConfigMap: svc-metadata │ ← Pricing, endpoints, description
+ │ Service: agent-svc- │ ← ClusterIP, port 8402
+ │ HTTPRoute: agent-svc-│ ← /services//* → port 8402
+ └─────────────────────────────┘
+ │
+ ▼
+ Traefik Gateway (traefik namespace)
+ │
+ ▼
+ Cloudflare Tunnel → https:///services//*
+```
+
+### Why a Sidecar Proxy?
+
+The agent writes **plain HTTP handlers** — no x402 awareness needed. A sidecar `x402-proxy` container handles all payment logic:
+
+1. Receives inbound request
+2. If no payment header → responds `402 Payment Required` with pricing
+3. If payment header present → verifies signature via facilitator
+4. If valid → proxies request to handler on `localhost:8080`
+5. Settles payment onchain via facilitator
+6. Returns handler response with `PAYMENT-RESPONSE` header
+
+**Benefits:**
+- Agent doesn't need to understand x402 protocol internals
+- Same proxy image reused across all services (already exists as `inference-gateway`)
+- Handler can be any language/framework — just serve HTTP on port 8080
+- Payment config is environment variables, not code
+
+### The x402 Proxy Image
+
+The existing `inference-gateway` (`cmd/inference-gateway/main.go`) is already a generic x402 reverse proxy. It takes `--upstream`, `--wallet`, `--price`, `--chain`, `--facilitator` flags and wraps any upstream HTTP service with x402 payment gates.
+
+**Reuse strategy:** The inference gateway image (`ghcr.io/obolnetwork/inference-gateway`) can proxy any upstream, not just Ollama. For `agent-services`, the upstream is `http://localhost:8080` (the agent's handler running in the same pod).
+
+If needed, we can extract the generic proxy into its own image (`ghcr.io/obolnetwork/x402-proxy`) later. For now, the inference gateway binary works as-is.
+
+---
+
+## Skill Structure
+
+```
+agent-services/
+├── SKILL.md
+├── scripts/
+│ └── service.sh # Deploy, list, update, teardown, monitor
+├── templates/
+│ ├── helmfile.yaml.gotmpl # Helmfile template for service deployment
+│ ├── handler.py.tmpl # Minimal Python handler scaffold
+│ └── metadata.json.tmpl # Service metadata template
+└── references/
+ └── x402-server-patterns.md # Pricing strategies, facilitator config, chain selection
+```
+
+### `service.sh` Commands
+
+```bash
+# === Lifecycle ===
+
+# Deploy a new service from a handler file
+sh scripts/service.sh deploy \
+ --name weather-api \
+ --handler ./my_handler.py \
+ --price 0.10 \
+ --chain base \
+ --wallet 0xYourAddress \
+ --description "Real-time weather data" \
+ --register # auto-register endpoint with ERC-8004
+
+# Deploy with the scaffold template (agent fills in the handler later)
+sh scripts/service.sh scaffold --name weather-api
+# → Creates handler.py from template, agent edits it, then deploys
+
+# Update handler code (patches ConfigMap, restarts pod)
+sh scripts/service.sh update --name weather-api --handler ./updated_handler.py
+
+# Update pricing (patches gateway config, no restart needed)
+sh scripts/service.sh set-price --name weather-api --price 0.05
+
+# Tear down a service (deletes namespace + all resources)
+sh scripts/service.sh teardown --name weather-api
+
+# === Discovery ===
+
+# List deployed services with status and URLs
+sh scripts/service.sh list
+
+# Show service details (pricing, endpoints, health, earnings)
+sh scripts/service.sh status --name weather-api
+
+# === Monitoring ===
+
+# Check USDC earnings for a service's wallet
+sh scripts/service.sh earnings --name weather-api
+
+# View service logs
+sh scripts/service.sh logs --name weather-api [--tail 100]
+
+# Health check
+sh scripts/service.sh health --name weather-api
+```
+
+### How `deploy` Works Internally
+
+```
+1. Validate inputs (handler file exists, chain supported, wallet valid)
+
+2. Create deployment directory:
+ $CONFIG_DIR/services//
+ ├── helmfile.yaml ← generated from template
+ ├── handler.py ← copied from --handler
+ └── values.yaml ← generated (price, chain, wallet, etc.)
+
+3. Run helmfile sync:
+ helmfile -f $CONFIG_DIR/services//helmfile.yaml sync
+
+ This creates:
+ - Namespace: agent-svc-
+ - ConfigMap: handler-code (contains handler.py)
+ - ConfigMap: svc-metadata (pricing, description, endpoints)
+ - Deployment: agent-svc- (2 containers: handler + x402 proxy)
+ - Service: agent-svc- (ClusterIP, port 8402)
+ - HTTPRoute: agent-svc- (path: /services//*)
+
+4. Wait for pod ready
+
+5. If --register flag:
+ sh scripts/identity.sh --from $WALLET register \
+ --uri "ipfs://$(pin metadata.json)"
+ # Or update existing agent's service endpoints
+```
+
+### Handler Template (`handler.py.tmpl`)
+
+The agent gets a minimal scaffold to fill in. No x402 awareness needed — just return HTTP responses.
+
+```python
+#!/usr/bin/env python3
+"""
+Agent service handler — {{.Name}}
+{{.Description}}
+
+This runs behind an x402 payment proxy. Requests that reach this
+handler have already been paid for. Just return the data.
+
+Serve on port 8080 (the proxy forwards paid requests here).
+"""
+import json
+from http.server import HTTPServer, BaseHTTPRequestHandler
+
+
+class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ """Handle GET requests."""
+ # TODO: implement your service logic here
+ data = {"message": "Hello from {{.Name}}"}
+
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps(data).encode())
+
+ def do_POST(self):
+ """Handle POST requests."""
+ content_length = int(self.headers.get("Content-Length", 0))
+ body = self.rfile.read(content_length) if content_length else b""
+
+ # TODO: process the request body
+ data = {"received": len(body)}
+
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps(data).encode())
+
+ def log_message(self, format, *args):
+ """Structured logging."""
+ print(f"[{{.Name}}] {args[0]}")
+
+
+if __name__ == "__main__":
+ server = HTTPServer(("0.0.0.0", 8080), Handler)
+ print(f"[{{.Name}}] Serving on :8080")
+ server.serve_forever()
+```
+
+### Helmfile Template (`helmfile.yaml.gotmpl`)
+
+```yaml
+releases:
+ - name: agent-svc-{{ .Values.name }}
+ namespace: agent-svc-{{ .Values.name }}
+ createNamespace: true
+ chart: bedag/raw
+ version: 2.1.0
+ values:
+ - resources:
+ # --- Handler code as ConfigMap ---
+ - apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: handler-code
+ data:
+ handler.py: |
+{{ .Values.handlerCode | indent 16 }}
+
+ # --- Service metadata for discovery ---
+ - apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: svc-metadata
+ labels:
+ app.kubernetes.io/part-of: obol.stack
+ obol.stack/app: agent-service
+ obol.stack/service-name: {{ .Values.name }}
+ data:
+ metadata.json: |
+ {
+ "name": "{{ .Values.name }}",
+ "description": "{{ .Values.description }}",
+ "pricing": {
+ "pricePerRequest": "{{ .Values.price }}",
+ "currency": "USDC",
+ "chain": "{{ .Values.chain }}"
+ },
+ "endpoints": {
+ "external": "{{ .Values.publicURL }}/services/{{ .Values.name }}",
+ "internal": "http://agent-svc-{{ .Values.name }}.agent-svc-{{ .Values.name }}.svc.cluster.local:8402"
+ }
+ }
+
+ # --- Deployment: handler + x402 proxy sidecar ---
+ - apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+ name: agent-svc-{{ .Values.name }}
+ spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: agent-svc-{{ .Values.name }}
+ template:
+ metadata:
+ labels:
+ app: agent-svc-{{ .Values.name }}
+ spec:
+ containers:
+ # Handler container — agent's business logic
+ - name: handler
+ image: python:3.12-slim
+ command: ["python3", "/app/handler.py"]
+ ports:
+ - containerPort: 8080
+ volumeMounts:
+ - name: handler-code
+ mountPath: /app
+ readinessProbe:
+ httpGet:
+ path: /
+ port: 8080
+ initialDelaySeconds: 3
+ periodSeconds: 5
+
+ # x402 proxy sidecar — payment verification + settlement
+ - name: x402-proxy
+ image: ghcr.io/obolnetwork/inference-gateway:latest
+ args:
+ - --listen=:8402
+ - --upstream=http://localhost:8080
+ - --wallet={{ .Values.wallet }}
+ - --price={{ .Values.price }}
+ - --chain={{ .Values.chain }}
+ - --facilitator={{ .Values.facilitator }}
+ ports:
+ - containerPort: 8402
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: 8402
+ initialDelaySeconds: 5
+ periodSeconds: 10
+
+ volumes:
+ - name: handler-code
+ configMap:
+ name: handler-code
+
+ # --- Service ---
+ - apiVersion: v1
+ kind: Service
+ metadata:
+ name: agent-svc-{{ .Values.name }}
+ spec:
+ selector:
+ app: agent-svc-{{ .Values.name }}
+ ports:
+ - port: 8402
+ targetPort: 8402
+ name: x402
+
+ # --- HTTPRoute (Traefik) ---
+ - apiVersion: gateway.networking.k8s.io/v1
+ kind: HTTPRoute
+ metadata:
+ name: agent-svc-{{ .Values.name }}
+ spec:
+ parentRefs:
+ - name: traefik-gateway
+ namespace: traefik
+ sectionName: web
+ rules:
+ - matches:
+ - path:
+ type: PathPrefix
+ value: /services/{{ .Values.name }}
+ filters:
+ - type: URLRewrite
+ urlRewrite:
+ path:
+ type: ReplacePrefixMatch
+ replacePrefixMatch: /
+ backendRefs:
+ - name: agent-svc-{{ .Values.name }}
+ port: 8402
+```
+
+---
+
+## Integration With Existing Skills
+
+| Skill | Integration point |
+|-------|------------------|
+| `agent-identity` | `--register` flag calls `identity.sh register` or `identity.sh set-uri` to advertise the service endpoint in ERC-8004 |
+| `local-ethereum-wallet` | Wallet address for x402 payment settlement; `signer.py` for any onchain operations |
+| `ethereum-networks` | `rpc.sh` to check USDC balance, query payment transactions, verify settlement |
+| `obol-stack` | `kube.py` to monitor service pod health, logs, events |
+| `standards` | x402 protocol reference, pricing strategies, facilitator documentation |
+
+---
+
+## RBAC Requirements
+
+The OpenClaw pod currently has **read-only access to its own namespace**. To deploy services, it needs:
+
+### Option A: Expand OpenClaw's RBAC (Simple, Less Isolated)
+
+Add a ClusterRole that lets OpenClaw create resources in `agent-svc-*` namespaces:
+
+```yaml
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: openclaw-service-deployer
+rules:
+ - apiGroups: [""]
+ resources: ["namespaces", "configmaps", "services"]
+ verbs: ["get", "list", "create", "update", "delete"]
+ - apiGroups: ["apps"]
+ resources: ["deployments"]
+ verbs: ["get", "list", "create", "update", "delete"]
+ - apiGroups: ["gateway.networking.k8s.io"]
+ resources: ["httproutes"]
+ verbs: ["get", "list", "create", "update", "delete"]
+```
+
+### Option B: Deploy via `obol` CLI (Preferred, Uses Existing Patterns)
+
+Don't give OpenClaw direct k8s write access. Instead:
+
+1. `service.sh` writes the helmfile + handler to the **host PVC** (same pattern as skills injection)
+2. A lightweight controller or CronJob watches for new service definitions and runs `helmfile sync`
+3. Or: the agent calls `obol` CLI via the existing passthrough pattern
+
+**Recommended: Option B** — it follows the existing principle that OpenClaw doesn't mutate cluster state directly. The `obol` binary handles deployment, OpenClaw handles the intent.
+
+In practice, `service.sh deploy` would:
+1. Write helmfile + handler + values to `$DATA_DIR/services//`
+2. Call the `obol` CLI wrapper (already available in `$PATH`) to run helmfile sync
+3. The `obol` CLI has full kubeconfig access and handles the deployment
+
+This mirrors how `obol network install` + `obol network sync` work — config is staged, then synced.
+
+---
+
+## Service Lifecycle
+
+### Deploy
+```
+Agent writes handler → service.sh deploy → helmfile sync → pod running → HTTPRoute active → tunnel exposes → ERC-8004 registered
+```
+
+### Update Handler
+```
+Agent edits handler → service.sh update → ConfigMap patched → pod restarted → same URL, new logic
+```
+
+### Update Price
+```
+service.sh set-price → x402 proxy config updated → restarts sidecar only → price change takes effect
+```
+
+### Teardown
+```
+service.sh teardown → helmfile destroy → namespace deleted → ERC-8004 URI updated (mark inactive)
+```
+
+### Monitor
+```
+service.sh earnings → rpc.sh checks USDC balance → shows delta since deployment
+service.sh status → pod health + request count + uptime + reputation score
+```
+
+---
+
+## Pricing Strategies (Reference Material)
+
+The `x402-server-patterns.md` reference would cover:
+
+### Scheme: `exact` (Live)
+Fixed price per request. Simple, predictable.
+```
+Price: $0.10 USDC per weather query
+Price: $0.001 USDC per data point
+```
+
+### Scheme: `upto` (Emerging)
+Client authorises a maximum, server settles actual cost. Critical for metered services:
+```
+LLM inference: max $0.50, settle per token generated
+Compute jobs: max $1.00, settle per second of runtime
+Data queries: max $0.10, settle per row returned
+```
+
+### Free Tier Pattern
+Set price to 0 for discovery/reputation building. Upgrade later:
+```bash
+# Start free to build reputation
+sh scripts/service.sh deploy --name weather-api --handler ./handler.py --price 0 --register
+
+# After building reputation, add pricing
+sh scripts/service.sh set-price --name weather-api --price 0.05
+```
+
+### Chain Selection
+| Chain | Gas cost per settlement | Best for |
+|-------|------------------------|----------|
+| Base | ~$0.001 | Consumer services, micropayments |
+| Base Sepolia | Free (testnet) | Development, testing |
+| Polygon | ~$0.005 | Medium-value services |
+| Avalanche | ~$0.01 | Higher-value services |
+
+---
+
+## Implementation Order
+
+| Phase | Work | Effort | Dependencies |
+|-------|------|--------|-------------|
+| **1** | Create `agent-services` SKILL.md | Small | None |
+| **2** | Create `service.sh` — scaffold + deploy + teardown | Large | Helmfile template |
+| **3** | Create helmfile.yaml.gotmpl + handler.py.tmpl | Medium | Inference gateway image |
+| **4** | Create `x402-server-patterns.md` reference | Small | None |
+| **5** | Add `service.sh` — update, set-price, list, status | Medium | Phase 2 |
+| **6** | Add `service.sh` — earnings monitoring, logs, health | Small | Phase 2 |
+| **7** | Add `--register` flag (ERC-8004 integration) | Small | `agent-identity` skill |
+| **8** | Add RBAC / obol CLI integration for deployment | Medium | Decision on Option A vs B |
+| **9** | Test end-to-end: deploy → pay → earn → rate cycle | Large | All phases |
+
+### Phase 1-4 delivers a working MVP. Phases 5-9 add polish and integration.
+
+---
+
+## Validation Criteria
+
+- [ ] Agent can scaffold a handler template with `service.sh scaffold`
+- [ ] Agent can deploy a handler that serves HTTP on a public URL
+- [ ] Unauthenticated requests receive `402 Payment Required` with pricing info
+- [ ] Paid requests (valid x402 signature) reach the handler and return data
+- [ ] Payment settles onchain (USDC transferred to agent's wallet)
+- [ ] Agent can update handler code without changing the URL
+- [ ] Agent can update pricing without redeploying
+- [ ] Agent can tear down a service cleanly
+- [ ] Agent can list deployed services with status
+- [ ] Agent can check USDC earnings
+- [ ] `--register` flag creates/updates ERC-8004 registration with service endpoint
+- [ ] Service is discoverable by other agents via ERC-8004 + reputation queries
+- [ ] All scripts are POSIX sh, work in the OpenClaw pod
+- [ ] Follows existing Obol Stack patterns (helmfile, namespace isolation, Traefik HTTPRoute)
+
+---
+
+## Open Questions
+
+1. **x402 proxy image:** Reuse `inference-gateway` as-is, or extract a generic `x402-proxy` image? The inference gateway already accepts `--upstream` so it works, but the name is misleading for non-inference services.
+
+2. **Handler language:** Start with Python-only (stdlib HTTPServer, no dependencies)? Or support a generic Docker image where the agent provides a Dockerfile?
+
+3. **ConfigMap size limit:** Handler code goes in a ConfigMap (1MB limit). For larger services, should we use the PVC injection pattern instead? 1MB is generous for a Python handler but could be limiting for services with bundled data.
+
+4. **Multi-endpoint services:** One handler = one service = one price? Or support multiple endpoints with different prices within a single service? The x402 middleware can be configured per-path.
+
+5. **Service discovery by other agents:** Beyond ERC-8004 registration, should there be an in-cluster service registry (ConfigMap-based, like the inference metadata pattern) so co-located agents can discover each other without going onchain?
+
+6. **Auto-restart on failure:** Should the skill configure liveness probes to auto-restart crashed handlers? The template includes readiness probes but not liveness.
+
+7. **Rate limiting:** Should there be built-in rate limiting to prevent abuse even with x402 payments? Or is the payment itself sufficient protection?
diff --git a/renovate.json b/renovate.json
index 5dbbe97..987acf8 100644
--- a/renovate.json
+++ b/renovate.json
@@ -47,14 +47,14 @@
},
{
"customType": "regex",
- "description": "Update Foundry nightly version from upstream GitHub releases",
+ "description": "Update Foundry version from upstream GitHub releases",
"matchStrings": [
- "#\\s*renovate:\\s*datasource=(?.*?)\\s+depName=(?.*?)\\n(?nightly-[0-9a-f]+)"
+ "#\\s*renovate:\\s*datasource=(?.*?)\\s+depName=(?.*?)\\n(?v[0-9]+\\.[0-9]+\\.[0-9]+[^\\s]*)"
],
"fileMatch": [
"^internal/openclaw/FOUNDRY_VERSION$"
],
- "versioningTemplate": "loose"
+ "versioningTemplate": "semver"
},
{
"customType": "regex",
@@ -143,7 +143,7 @@
"groupName": "OpenClaw updates"
},
{
- "description": "Group Foundry updates (weekly to avoid nightly PR noise)",
+ "description": "Group Foundry updates",
"matchDatasources": [
"github-releases"
],