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" ],