diff --git a/go.mod b/go.mod index eb85a34..c3d593a 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,11 @@ 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/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 ) @@ -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 a016705..a2d47d4 100644 --- a/internal/embed/embed_skills_test.go +++ b/internal/embed/embed_skills_test.go @@ -3,7 +3,6 @@ package embed import ( "os" "path/filepath" - "sort" "testing" ) @@ -13,15 +12,15 @@ func TestGetEmbeddedSkillNames(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - want := []string{"distributed-validators", "ethereum-networks", "ethereum-wallet", "obol-stack"} - sort.Strings(names) - - if len(names) != len(want) { - t.Fatalf("got %d skills %v, want %d %v", len(names), names, len(want), want) + // Core skills that must always be present + required := []string{"distributed-validators", "ethereum-networks", "local-wallet", "obol-stack"} + nameSet := make(map[string]bool, len(names)) + for _, n := range names { + nameSet[n] = true } - for i := range want { - if names[i] != want[i] { - t.Errorf("skill[%d] = %q, want %q", i, names[i], want[i]) + for _, r := range required { + if !nameSet[r] { + t.Errorf("required skill %q not found in embedded skills %v", r, names) } } } @@ -34,7 +33,7 @@ func TestCopySkills(t *testing.T) { } // Every skill must have a SKILL.md - skills := []string{"distributed-validators", "ethereum-networks", "ethereum-wallet", "obol-stack"} + skills := []string{"distributed-validators", "ethereum-networks", "local-wallet", "obol-stack"} for _, skill := range skills { skillMD := filepath.Join(destDir, skill, "SKILL.md") info, err := os.Stat(skillMD) @@ -73,7 +72,7 @@ func TestCopySkillsSkipsExisting(t *testing.T) { destDir := t.TempDir() // Pre-create a skill directory with custom content - customDir := filepath.Join(destDir, "ethereum-wallet") + customDir := filepath.Join(destDir, "local-wallet") if err := os.MkdirAll(customDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } diff --git a/internal/embed/skills/ethereum-networks/SKILL.md b/internal/embed/skills/ethereum-networks/SKILL.md index ae4227a..9f28b5d 100644 --- a/internal/embed/skills/ethereum-networks/SKILL.md +++ b/internal/embed/skills/ethereum-networks/SKILL.md @@ -18,7 +18,7 @@ Query Ethereum blockchain data through the local eRPC gateway. Supports any JSON ## When NOT to Use -- Sending transactions or signing (read-only, no private keys) +- Sending transactions, signing, or deploying contracts β€” use `ethereum-wallet` - Validator monitoring β€” use `distributed-validators` - Kubernetes pod diagnostics β€” use `obol-stack` diff --git a/internal/embed/skills/ethereum-wallet/SKILL.md b/internal/embed/skills/ethereum-wallet/SKILL.md index 531b142..535b1d8 100644 --- a/internal/embed/skills/ethereum-wallet/SKILL.md +++ b/internal/embed/skills/ethereum-wallet/SKILL.md @@ -1,38 +1,117 @@ --- name: ethereum-wallet -description: "Sign and send Ethereum transactions via a remote Web3Signer. This skill is not yet implemented β€” it will connect to a Web3Signer instance using a URI and auth token to sign transactions, deploy contracts, and manage validator operations." -metadata: { "openclaw": { "emoji": "πŸ”", "requires": { "bins": ["curl"] } } } +description: "Sign and send Ethereum transactions via the local Web3Signer. Use when asked to send ETH, sign messages, or interact with contracts that modify state." +metadata: { "openclaw": { "emoji": "πŸ”", "requires": { "bins": ["python3"] } } } --- # Ethereum Wallet -> **This skill is coming soon.** It is not yet functional β€” the instructions below describe what it will do when complete. +Sign and send Ethereum transactions through the local Web3Signer instance. +Keys are pre-generated during setup β€” this skill signs and submits only. -## What This Will Do +## When to Use -The ethereum-wallet skill will let you sign and send Ethereum transactions through a remote [Web3Signer](https://docs.web3signer.consensys.io/) instance. Web3Signer is a remote signing service that keeps private keys secure and separate from the application. +- Listing available signing addresses (wallets) +- Sending ETH to an address +- Signing messages or typed data (EIP-712) +- Signing transactions for later broadcast +- Calling contract functions that modify state (write operations) +- Deploying smart contracts -### Planned capabilities +## When NOT to Use -- **Send ETH** to any address -- **Call contract functions** that modify state (not just read β€” that's what `ethereum-networks` does) -- **Deploy contracts** from bytecode -- **Sign messages** for off-chain verification -- **Manage validator operations** like voluntary exits +- Reading blockchain data (balances, blocks, transactions) β€” use `ethereum-networks` +- Creating new keys β€” keys are managed by the `obol` CLI, not this skill +- Monitoring validators β€” use `distributed-validators` +- Kubernetes diagnostics β€” use `obol-stack` -### Configuration +## Quick Start -When ready, this skill will need two things: +```bash +# List signing addresses +python3 scripts/signer.py accounts -1. **Web3Signer URI** β€” the URL of your Web3Signer instance (e.g. `http://web3signer.svc.cluster.local:9000`) -2. **Auth token** β€” a bearer token for authenticating with the signer +# Check web3signer health +python3 scripts/signer.py health -These will be provided during setup via `obol openclaw setup` or environment variables. +# Sign a message +python3 scripts/signer.py sign 0xYourAddress 0xdeadbeef -## Current Status +# Sign a transaction (returns raw signed tx hex) +python3 scripts/signer.py sign-tx \ + --from 0xYourAddress --to 0xRecipient --value 0xDE0B6B3A7640000 -This skill is a placeholder. If you need to: +# Sign AND submit a transaction via eRPC +python3 scripts/signer.py send-tx \ + --from 0xYourAddress --to 0xRecipient --value 0xDE0B6B3A7640000 -- **Read** blockchain data (balances, blocks, transactions) β€” use the `ethereum-networks` skill -- **Monitor** distributed validators β€” use the `distributed-validators` skill -- **Sign transactions** β€” this will need to wait until the ethereum-wallet skill is implemented +# Sign EIP-712 typed data +python3 scripts/signer.py sign-typed 0xYourAddress '{"types":{...},"primaryType":"...","domain":{...},"message":{...}}' +``` + +## Available Commands + +| Command | Params | Description | +|---------|--------|-------------| +| `accounts` | none | List signing addresses from web3signer | +| `health` | none | Check web3signer `/upcheck` endpoint | +| `sign` | `address data` | Sign arbitrary hex data (`eth_sign`) | +| `sign-tx` | `--from --to [--value] [--data] [--gas] [--nonce] [--network]` | Sign a tx, return raw signed hex | +| `sign-typed` | `address typed-data-json` | Sign EIP-712 typed data | +| `send-tx` | `--from --to [--value] [--data] [--network]` | Sign AND broadcast via eRPC | + +## Transaction Submission Flow + +`send-tx` does the following: + +1. Fetches nonce, gas price, chain ID from eRPC (unless provided) +2. Calls `eth_signTransaction` on web3signer β€” returns RLP-encoded signed tx +3. Calls `eth_sendRawTransaction` on eRPC β€” returns tx hash +4. Reports the tx hash (use `ethereum-networks` skill to check receipt later) + +## Multi-Network Support + +By default, transactions target `mainnet`. Use `--network` to change: + +```bash +python3 scripts/signer.py send-tx --network hoodi \ + --from 0xYourAddress --to 0xRecipient --value 0xDE0B6B3A7640000 +``` + +The signing key is chain-agnostic β€” the same address works on any EVM network. +Network routing goes through eRPC at `/rpc/{network}`. + +## Values Are in Hex Wei + +All `--value` amounts are hex-encoded wei, matching the JSON-RPC standard: + +| Amount | Hex Wei | +|--------|---------| +| 1 ETH | `0xDE0B6B3A7640000` | +| 0.1 ETH | `0x16345785D8A0000` | +| 0.01 ETH | `0x2386F26FC10000` | +| 1 Gwei | `0x3B9ACA00` | + +The script does NOT auto-convert from ETH decimal notation. + +## Constraints + +- **Shell is `sh`, not `bash`** β€” do not use bashisms like `${var//pattern}`, `${var:offset}`, `[[ ]]`, or arrays. Use POSIX-compatible syntax only +- **Python stdlib only** β€” only the Python 3.11 standard library is available. Do not import `web3`, `eth_abi`, `rlp`, `pysha3`, or any third-party package +- **No key creation** β€” keys are managed by the `obol` CLI. If no keys exist, tell the user to run `obol agent init` +- **Local only** β€” always use the in-cluster web3signer at `$WEB3SIGNER_URL`, never call external signing services +- **Always check for null** β€” RPC methods may return `null` for unknown hashes or pending state. Always check `if result is not None` before accessing fields +- **Confirm before sending** β€” always show the user what will be signed before executing `send-tx` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `WEB3SIGNER_URL` | `http://web3signer:9000` | Web3Signer service URL | +| `ERPC_URL` | `http://erpc.erpc.svc.cluster.local:4000/rpc` | eRPC gateway base URL | +| `ERPC_NETWORK` | `mainnet` | Default network for eRPC routing | + +## See Also + +- `references/web3signer-api.md` β€” ETH1 JSON-RPC and REST API reference +- `ethereum-networks` skill β€” read-only blockchain queries via eRPC diff --git a/internal/embed/skills/ethereum-wallet/references/web3signer-api.md b/internal/embed/skills/ethereum-wallet/references/web3signer-api.md new file mode 100644 index 0000000..8580731 --- /dev/null +++ b/internal/embed/skills/ethereum-wallet/references/web3signer-api.md @@ -0,0 +1,97 @@ +# Web3Signer ETH1 API Reference + +Base URL: `$WEB3SIGNER_URL` (default: `http://web3signer:9000`) + +## JSON-RPC Methods + +All methods use `POST` to the base URL with `Content-Type: application/json`. + +Request format: +```json +{"jsonrpc": "2.0", "method": "", "params": [...], "id": 1} +``` + +| Method | Params | Returns | Description | +|--------|--------|---------|-------------| +| `eth_accounts` | `[]` | `["0x..."]` | List signer addresses | +| `eth_sign` | `[address, data]` | `"0x..."` (65-byte signature) | Sign with Ethereum prefix | +| `eth_signTransaction` | `[txObject]` | `"0x..."` (signed RLP) | Sign tx for later broadcast | +| `eth_signTypedData` | `[address, typedData]` | `"0x..."` (65-byte signature) | EIP-712 typed data signing | +| `eth_sendTransaction` | `[txObject]` | `"0x..."` (tx hash) | Sign + submit (needs downstream config) | + +## REST API Endpoints + +| Method | Path | Description | Response | +|--------|------|-------------|----------| +| `GET` | `/upcheck` | Health check | `"OK"` (200) or 500 | +| `GET` | `/api/v1/eth1/publicKeys` | List SECP256K1 public keys | `["0x04..."]` (JSON array) | +| `POST` | `/api/v1/eth1/sign/{pubkey}` | Sign raw data | signature hex string | +| `POST` | `/reload` | Reload key configurations | 202 Accepted | +| `GET` | `/reload` | Check reload status | `idle`, `running`, `completed`, `failed` | + +## Transaction Object Fields + +Used with `eth_signTransaction` and `eth_sendTransaction`: + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `from` | yes | `DATA` (20 bytes) | Signer address | +| `to` | yes* | `DATA` (20 bytes) | Recipient (* omit for contract deploy) | +| `value` | no | `QUANTITY` (hex) | Wei to send | +| `data` | no | `DATA` (hex) | Calldata or contract bytecode | +| `gas` | no | `QUANTITY` (hex) | Gas limit | +| `gasPrice` | no | `QUANTITY` (hex) | Gas price (legacy tx) | +| `maxFeePerGas` | no | `QUANTITY` (hex) | EIP-1559 max fee | +| `maxPriorityFeePerGas` | no | `QUANTITY` (hex) | EIP-1559 priority fee | +| `nonce` | no | `QUANTITY` (hex) | Sender nonce | +| `chainId` | no | `QUANTITY` (hex) | Chain ID (prevents replay) | + +## eth_sign Details + +Signs data with the Ethereum-specific prefix: `"\x19Ethereum Signed Message:\n" + len(message) + message`. + +**Params**: `[address, data]` +- `address`: `"0x..."` β€” 20-byte signer address +- `data`: `"0x..."` β€” hex-encoded data to sign + +**Returns**: `"0x..."` β€” 65-byte signature (r + s + v) + +## eth_signTypedData Details + +Signs structured data per [EIP-712](https://eips.ethereum.org/EIPS/eip-712). + +**Params**: `[address, typedData]` +- `address`: `"0x..."` β€” 20-byte signer address +- `typedData`: EIP-712 object with `types`, `primaryType`, `domain`, `message` + +**Returns**: `"0x..."` β€” 65-byte signature (r + s + v) + +## Error Responses + +| HTTP Code | Meaning | +|-----------|---------| +| 400 | Bad request (malformed params) | +| 404 | Public key not found in keystore | +| 500 | Internal server error | + +## Key Configuration (TOML) + +Web3Signer loads keys from TOML configuration files in the key store directory. + +### Raw hex key +```toml +[metadata] +description = "my-key" + +[signing] +type = "file-raw" +filename = "/data/mykey.hex" +``` + +### Encrypted keystore (V3) +```toml +[signing] +type = "file-keystore" +keystoreFile = "/data/keystore.json" +keystorePasswordFile = "/data/password.txt" +``` diff --git a/internal/embed/skills/ethereum-wallet/scripts/signer.py b/internal/embed/skills/ethereum-wallet/scripts/signer.py new file mode 100644 index 0000000..2071d20 --- /dev/null +++ b/internal/embed/skills/ethereum-wallet/scripts/signer.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +""" +Ethereum wallet operations via local Web3Signer. + +Signs and submits transactions using the in-cluster Web3Signer instance. +Keys are pre-provisioned by 'obol agent init' β€” this script never creates +or accesses private key material. + +Environment variables: + WEB3SIGNER_URL β€” default: http://web3signer:9000 + ERPC_URL β€” default: http://erpc.erpc.svc.cluster.local:4000/rpc + ERPC_NETWORK β€” default: mainnet +""" + +import json +import os +import sys +from urllib.request import Request, urlopen +from urllib.error import HTTPError, URLError + +WEB3SIGNER_URL = os.environ.get("WEB3SIGNER_URL", "http://web3signer:9000") +ERPC_BASE = os.environ.get("ERPC_URL", "http://erpc.erpc.svc.cluster.local:4000/rpc") +ERPC_NETWORK = os.environ.get("ERPC_NETWORK", "mainnet") + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + +def web3signer_rpc(method, params): + """JSON-RPC 2.0 call to Web3Signer.""" + payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1} + req = Request( + WEB3SIGNER_URL, + json.dumps(payload).encode(), + {"Content-Type": "application/json"}, + ) + try: + resp = json.load(urlopen(req)) + except HTTPError as e: + print("Web3Signer HTTP %d: %s" % (e.code, e.read().decode()), file=sys.stderr) + sys.exit(1) + except URLError as e: + print("Web3Signer unreachable: %s" % e.reason, file=sys.stderr) + print("Is Web3Signer running? Check: python3 scripts/signer.py health", file=sys.stderr) + sys.exit(1) + if "error" in resp: + msg = resp["error"].get("message", str(resp["error"])) + print("Web3Signer RPC error: %s" % msg, file=sys.stderr) + sys.exit(1) + return resp.get("result") + + +def web3signer_rest(method, path): + """REST API call to Web3Signer. Returns response body as string.""" + req = Request("%s%s" % (WEB3SIGNER_URL, path)) + req.method = method + try: + return urlopen(req).read().decode() + except HTTPError as e: + print("Web3Signer REST %d: %s" % (e.code, e.read().decode()), file=sys.stderr) + sys.exit(1) + except URLError as e: + print("Web3Signer unreachable: %s" % e.reason, file=sys.stderr) + sys.exit(1) + + +def erpc_rpc(method, params, network=None): + """JSON-RPC 2.0 call to eRPC.""" + net = network if network else ERPC_NETWORK + url = "%s/%s" % (ERPC_BASE, net) + payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1} + req = Request(url, json.dumps(payload).encode(), {"Content-Type": "application/json"}) + try: + resp = json.load(urlopen(req)) + except HTTPError as e: + print("eRPC HTTP %d: %s" % (e.code, e.read().decode()), file=sys.stderr) + sys.exit(1) + except URLError as e: + print("eRPC unreachable at %s: %s" % (url, e.reason), file=sys.stderr) + sys.exit(1) + if "error" in resp: + msg = resp["error"].get("message", str(resp["error"])) + print("eRPC RPC error: %s" % msg, file=sys.stderr) + sys.exit(1) + return resp.get("result") + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + +def cmd_health(): + """Check Web3Signer /upcheck endpoint.""" + body = web3signer_rest("GET", "/upcheck") + print(body.strip()) + + +def cmd_accounts(): + """List signing addresses via eth_accounts JSON-RPC.""" + accounts = web3signer_rpc("eth_accounts", []) + if not accounts: + print("No signing keys found.") + print("Keys are created by 'obol agent init'. Run that first.") + return + print("Signing addresses (%d):" % len(accounts)) + for addr in accounts: + print(" %s" % addr) + + +def cmd_sign(address, data): + """Sign arbitrary hex data with eth_sign.""" + sig = web3signer_rpc("eth_sign", [address, data]) + print(sig) + + +def cmd_sign_typed(address, typed_data_str): + """Sign EIP-712 typed data with eth_signTypedData.""" + try: + typed_data = json.loads(typed_data_str) + except json.JSONDecodeError as e: + print("Invalid typed data JSON: %s" % e, file=sys.stderr) + sys.exit(1) + sig = web3signer_rpc("eth_signTypedData", [address, typed_data]) + print(sig) + + +def cmd_sign_tx(args): + """Sign a transaction with eth_signTransaction. Returns raw signed tx hex.""" + tx, network = build_tx_from_args(args) + + # Auto-fill missing fields from eRPC + if "nonce" not in tx: + tx["nonce"] = erpc_rpc("eth_getTransactionCount", [tx["from"], "pending"], network) + if "gasPrice" not in tx: + tx["gasPrice"] = erpc_rpc("eth_gasPrice", [], network) + if "chainId" not in tx: + tx["chainId"] = erpc_rpc("eth_chainId", [], network) + if "gas" not in tx: + estimate_tx = {k: v for k, v in tx.items() if k in ("from", "to", "value", "data")} + tx["gas"] = erpc_rpc("eth_estimateGas", [estimate_tx], network) + + signed = web3signer_rpc("eth_signTransaction", [tx]) + if signed is None: + print("Error: eth_signTransaction returned null", file=sys.stderr) + sys.exit(1) + + # eth_signTransaction may return a hex string (raw RLP) or an object + if isinstance(signed, str): + print(signed) + elif isinstance(signed, dict) and "raw" in signed: + print(signed["raw"]) + else: + print(json.dumps(signed, indent=2)) + + +def cmd_send_tx(args): + """Sign and broadcast a transaction via eRPC.""" + tx, network = build_tx_from_args(args) + + # Auto-fill missing fields from eRPC + if "nonce" not in tx: + tx["nonce"] = erpc_rpc("eth_getTransactionCount", [tx["from"], "pending"], network) + if "gasPrice" not in tx: + tx["gasPrice"] = erpc_rpc("eth_gasPrice", [], network) + if "chainId" not in tx: + tx["chainId"] = erpc_rpc("eth_chainId", [], network) + if "gas" not in tx: + estimate_tx = {k: v for k, v in tx.items() if k in ("from", "to", "value", "data")} + tx["gas"] = erpc_rpc("eth_estimateGas", [estimate_tx], network) + + # Sign via web3signer + signed = web3signer_rpc("eth_signTransaction", [tx]) + if signed is None: + print("Error: eth_signTransaction returned null", file=sys.stderr) + sys.exit(1) + + # Extract raw signed transaction + if isinstance(signed, str): + raw_tx = signed + elif isinstance(signed, dict) and "raw" in signed: + raw_tx = signed["raw"] + else: + print("Error: unexpected eth_signTransaction response: %s" % json.dumps(signed), file=sys.stderr) + sys.exit(1) + + # Submit to eRPC + tx_hash = erpc_rpc("eth_sendRawTransaction", [raw_tx], network) + if tx_hash is None: + print("Error: eth_sendRawTransaction returned null", file=sys.stderr) + sys.exit(1) + + print("Transaction submitted: %s" % tx_hash) + print("Network: %s" % network) + print("Check receipt: python3 ../ethereum-networks/scripts/rpc.py --network %s eth_getTransactionReceipt %s" % (network, tx_hash)) + + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +def build_tx_from_args(args): + """Parse --from, --to, --value, --data, --gas, --nonce, --network from args list.""" + tx = {} + network = ERPC_NETWORK + i = 0 + while i < len(args): + if args[i] == "--from" and i + 1 < len(args): + tx["from"] = args[i + 1] + i += 2 + elif args[i] == "--to" and i + 1 < len(args): + tx["to"] = args[i + 1] + i += 2 + elif args[i] == "--value" and i + 1 < len(args): + tx["value"] = args[i + 1] + i += 2 + elif args[i] == "--data" and i + 1 < len(args): + tx["data"] = args[i + 1] + i += 2 + elif args[i] == "--gas" and i + 1 < len(args): + tx["gas"] = args[i + 1] + i += 2 + elif args[i] == "--nonce" and i + 1 < len(args): + tx["nonce"] = args[i + 1] + i += 2 + elif args[i] == "--network" and i + 1 < len(args): + network = args[i + 1] + i += 2 + else: + print("Unknown argument: %s" % args[i], file=sys.stderr) + sys.exit(1) + + if "from" not in tx: + print("Error: --from is required", file=sys.stderr) + sys.exit(1) + + return tx, network + + +def usage(): + print("""Usage: python3 signer.py [args...] + +Commands: + accounts List signing addresses + health Check Web3Signer health + sign
Sign arbitrary data (eth_sign) + sign-tx --from --to [--value ] [--data ] [--gas ] [--nonce ] [--network ] + Sign a transaction (returns raw signed tx) + send-tx --from --to [--value ] [--data ] [--network ] + Sign and broadcast a transaction + sign-typed
Sign EIP-712 typed data + +Environment: + WEB3SIGNER_URL Web3Signer URL (default: http://web3signer:9000) + ERPC_URL eRPC base URL (default: http://erpc.erpc.svc.cluster.local:4000/rpc) + ERPC_NETWORK Default network (default: mainnet)""") + + +def main(): + args = sys.argv[1:] + if not args: + usage() + sys.exit(1) + + command = args[0] + + if command == "health": + cmd_health() + elif command == "accounts": + cmd_accounts() + elif command == "sign": + if len(args) < 3: + print("Usage: signer.py sign
", file=sys.stderr) + sys.exit(1) + cmd_sign(args[1], args[2]) + elif command == "sign-typed": + if len(args) < 3: + print("Usage: signer.py sign-typed
", file=sys.stderr) + sys.exit(1) + cmd_sign_typed(args[1], args[2]) + elif command == "sign-tx": + cmd_sign_tx(args[1:]) + elif command == "send-tx": + cmd_send_tx(args[1:]) + else: + print("Unknown command: %s" % command, file=sys.stderr) + usage() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/internal/openclaw/integration_test.go b/internal/openclaw/integration_test.go index 6568094..cf36ec0 100644 --- a/internal/openclaw/integration_test.go +++ b/internal/openclaw/integration_test.go @@ -600,7 +600,7 @@ func TestIntegration_SkillsStagedOnSync(t *testing.T) { // 1. Verify skills were staged in the deployment directory deployDir := deploymentPath(cfg, id) skillsDir := filepath.Join(deployDir, "skills") - expectedSkills := []string{"distributed-validators", "ethereum-networks", "ethereum-wallet", "obol-stack"} + expectedSkills := []string{"distributed-validators", "ethereum-networks", "local-wallet", "obol-stack"} for _, skill := range expectedSkills { skillMD := filepath.Join(skillsDir, skill, "SKILL.md") @@ -664,7 +664,7 @@ func TestIntegration_SkillsVisibleInPod(t *testing.T) { ) t.Logf("skills visible in pod:\n%s", output) - expectedSkills := []string{"distributed-validators", "ethereum-networks", "ethereum-wallet", "obol-stack"} + expectedSkills := []string{"distributed-validators", "ethereum-networks", "local-wallet", "obol-stack"} for _, skill := range expectedSkills { if !strings.Contains(output, skill) { t.Errorf("skill %q not visible in pod; ls output:\n%s", skill, output) @@ -837,7 +837,7 @@ func TestIntegration_SkillInference(t *testing.T) { {"ethereum-networks", []string{"ethereum-networks", "ethereum networks", "blockchain"}}, {"distributed-validators", []string{"distributed-validators", "distributed validator", "dvt"}}, {"obol-stack", []string{"obol-stack", "obol stack", "kubernetes", "k8s"}}, - {"ethereum-wallet", []string{"ethereum-wallet", "ethereum wallet", "wallet"}}, + {"local-wallet", []string{"local-wallet", "ethereum wallet", "wallet"}}, } for _, sc := range skillChecks { diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go index 984d807..8bd294a 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -133,6 +133,10 @@ func Onboard(cfg *config.Config, opts OnboardOptions) error { if err := os.WriteFile(filepath.Join(deploymentDir, "helmfile.yaml"), []byte(helmfileContent), 0644); err != nil { return fmt.Errorf("failed to update helmfile.yaml: %w", err) } + + // Provision web3signer key + values if not already present. + ensureWeb3Signer(cfg, id, deploymentDir) + if opts.Sync { if err := doSync(cfg, id); err != nil { return err @@ -206,25 +210,55 @@ 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 helmfile.yaml referencing obol/openclaw + web3signer helmfileContent := generateHelmfile(id, namespace) if err := os.WriteFile(filepath.Join(deploymentDir, "helmfile.yaml"), []byte(helmfileContent), 0644); err != nil { os.RemoveAll(deploymentDir) return fmt.Errorf("failed to write helmfile.yaml: %w", err) } + // Generate Web3Signer signing key and provision to host-path PVC. + // The key is created before deployment so web3signer can load it on startup. + fmt.Println("\nGenerating Web3Signer signing key...") + signingKey, err := GenerateSigningKey() + if err != nil { + os.RemoveAll(deploymentDir) + return fmt.Errorf("failed to generate signing key: %w", err) + } + keysDir := Web3SignerKeysPath(cfg, id) + keyLabel := fmt.Sprintf("obol-agent-%s", id) + if err := ProvisionKeyFiles(keysDir, signingKey, keyLabel); err != nil { + os.RemoveAll(deploymentDir) + return fmt.Errorf("failed to provision signing key: %w", err) + } + + // Write Web3Signer Helm values + web3signerValues := generateWeb3SignerValues(id) + if err := os.WriteFile(filepath.Join(deploymentDir, "values-web3signer.yaml"), []byte(web3signerValues), 0644); err != nil { + os.RemoveAll(deploymentDir) + return fmt.Errorf("failed to write web3signer values: %w", err) + } + fmt.Printf("\nβœ“ OpenClaw instance configured!\n") fmt.Printf(" Deployment: %s/%s\n", appName, id) fmt.Printf(" Namespace: %s\n", namespace) fmt.Printf(" Hostname: %s\n", hostname) 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-web3signer.yaml Web3Signer configuration (ETH1 signing)\n") + fmt.Printf(" - helmfile.yaml Deployment configuration (openclaw v%s + web3signer v%s)\n", chartVersion, web3signerChartVersion) if len(secretData) > 0 { fmt.Printf(" - %s Local secret values (used to create %s in-cluster)\n", userSecretsFileName, userSecretsK8sSecretRef) } + // Display wallet address and backup warning + fmt.Printf("\n Agent wallet address: %s\n", signingKey.Address) + fmt.Printf("\n Back up your signing key:\n") + fmt.Printf(" cp %s/%s.yaml ~/obol-wallet-backup-%s.yaml\n", keysDir, signingKey.KeyID, id) + fmt.Printf("\n WARNING: This wallet feature is in alpha and may change rapidly.\n") + fmt.Printf(" Do not deposit mainnet funds you are not willing to lose.\n") + // Stage default skills to deployment directory (immediate, no cluster needed) fmt.Println("\nStaging default skills...") stageDefaultSkills(deploymentDir) @@ -256,6 +290,10 @@ func doSync(cfg *config.Config, id string) error { return fmt.Errorf("deployment not found: %s/%s\nDirectory: %s", appName, id, deploymentDir) } + // Ensure web3signer key + values exist (handles deployments created + // before web3signer was added, or manual values file deletion). + ensureWeb3Signer(cfg, id, deploymentDir) + helmfilePath := filepath.Join(deploymentDir, "helmfile.yaml") if _, err := os.Stat(helmfilePath); os.IsNotExist(err) { return fmt.Errorf("helmfile.yaml not found in: %s", deploymentDir) @@ -301,6 +339,10 @@ func doSync(cfg *config.Config, id string) error { return fmt.Errorf("helmfile sync failed: %w", err) } + // Apply web3signer-metadata ConfigMap (namespace now exists after helmfile sync). + // Read the signing key address from the provisioned key files. + applyWeb3SignerMetadata(cfg, id) + hostname := fmt.Sprintf("openclaw-%s.%s", id, defaultDomain) fmt.Printf("\nβœ“ OpenClaw installed successfully!\n") @@ -962,7 +1004,25 @@ func Delete(cfg *config.Config, id string, force bool) error { } if namespaceExists { - fmt.Printf("\nDeleting namespace %s...\n", namespace) + // Run helmfile destroy first to cleanly remove Helm releases (openclaw + web3signer). + // This ensures StatefulSet PVCs are properly cleaned up before namespace deletion. + helmfilePath := filepath.Join(deploymentDir, "helmfile.yaml") + helmfileBinary := filepath.Join(cfg.BinDir, "helmfile") + if _, err := os.Stat(helmfilePath); err == nil { + if _, err := os.Stat(helmfileBinary); err == nil { + fmt.Printf("\nRemoving Helm releases from %s...\n", namespace) + destroyCmd := exec.Command(helmfileBinary, "-f", helmfilePath, "destroy") + destroyCmd.Dir = deploymentDir + destroyCmd.Env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeconfigPath)) + destroyCmd.Stdout = os.Stdout + destroyCmd.Stderr = os.Stderr + if err := destroyCmd.Run(); err != nil { + fmt.Printf("Warning: helmfile destroy failed (will force-delete namespace): %v\n", err) + } + } + } + + fmt.Printf("Deleting namespace %s...\n", namespace) kubectlBinary := filepath.Join(cfg.BinDir, "kubectl") cmd := exec.Command(kubectlBinary, "delete", "namespace", namespace, "--force", "--grace-period=0") @@ -970,7 +1030,7 @@ func Delete(cfg *config.Config, id string, force bool) error { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to delete namespace: %w", err) + fmt.Printf("Warning: namespace deletion may still be in progress: %v\n", err) } fmt.Println("Namespace deleted") } @@ -990,6 +1050,15 @@ func Delete(cfg *config.Config, id string, force bool) error { } fmt.Printf("\nβœ“ OpenClaw %s deleted successfully!\n", id) + + // Note: signing key files on disk are intentionally preserved. + // They live in the data directory and are only removed by `obol stack purge --force`. + keysDir := Web3SignerKeysPath(cfg, id) + if _, err := os.Stat(keysDir); err == nil { + fmt.Printf("\n Signing key preserved at: %s\n", keysDir) + fmt.Printf(" To remove: obol stack purge --force\n") + } + return nil } @@ -1714,7 +1783,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 chart +// and a co-located Web3Signer instance for Ethereum transaction signing. func generateHelmfile(id, namespace string) string { return fmt.Sprintf(`# OpenClaw instance: %s # Managed by obol openclaw @@ -1722,6 +1792,8 @@ func generateHelmfile(id, namespace string) string { repositories: - name: obol url: https://obolnetwork.github.io/helm-charts/ + - name: ethereum + url: https://ethpandaops.github.io/ethereum-helm-charts releases: - name: openclaw @@ -1731,5 +1803,12 @@ releases: version: %s values: - values-obol.yaml -`, id, namespace, chartVersion) + + - name: web3signer + namespace: %s + chart: ethereum/web3signer + version: %s + values: + - values-web3signer.yaml +`, id, namespace, chartVersion, namespace, web3signerChartVersion) } diff --git a/internal/openclaw/skills_injection_test.go b/internal/openclaw/skills_injection_test.go index 02e31b0..a43893c 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", "ethereum-wallet", "obol-stack"} { + for _, skill := range []string{"distributed-validators", "ethereum-networks", "local-wallet", "obol-stack"} { 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", "ethereum-wallet", "obol-stack"} { + for _, skill := range []string{"distributed-validators", "ethereum-networks", "local-wallet", "obol-stack"} { 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) diff --git a/internal/openclaw/web3signer.go b/internal/openclaw/web3signer.go new file mode 100644 index 0000000..542570e --- /dev/null +++ b/internal/openclaw/web3signer.go @@ -0,0 +1,361 @@ +package openclaw + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "golang.org/x/crypto/sha3" +) + +const ( + web3signerChartVersion = "1.0.6" + web3signerImageTag = "25.12.0" + web3signerReleaseName = "web3signer" + web3signerPort = 9000 +) + +// Web3SignerKey holds the generated key material and derived identifiers. +type Web3SignerKey struct { + PrivateKeyHex string // 64 hex chars (32 bytes) + PublicKeyHex string // 130 hex chars (65 bytes, uncompressed with 04 prefix) + Address string // 0x-prefixed, 42 chars + KeyID string // short identifier used in filenames +} + +// GenerateSigningKey creates a new SECP256K1 private key and derives +// the Ethereum address from it. The key is suitable for Web3Signer's +// file-raw key type. +func GenerateSigningKey() (*Web3SignerKey, error) { + privKey, err := secp256k1.GeneratePrivateKey() + if err != nil { + return nil, fmt.Errorf("failed to generate secp256k1 key: %w", err) + } + + privBytes := privKey.Serialize() // 32 bytes + pubBytes := privKey.PubKey().SerializeUncompressed() // 65 bytes: 04 || x || y + + // Ethereum address: keccak256(pubkey_without_prefix)[12:] + hash := sha3.NewLegacyKeccak256() + hash.Write(pubBytes[1:]) // skip 0x04 prefix + addrBytes := hash.Sum(nil)[12:] + + // Generate a short key ID from randomness + idBytes := make([]byte, 4) + if _, err := rand.Read(idBytes); err != nil { + return nil, fmt.Errorf("failed to generate key ID: %w", err) + } + + return &Web3SignerKey{ + PrivateKeyHex: hex.EncodeToString(privBytes), + PublicKeyHex: "0x" + hex.EncodeToString(pubBytes), + Address: "0x" + hex.EncodeToString(addrBytes), + KeyID: hex.EncodeToString(idBytes), + }, nil +} + +// ProvisionKeyFiles writes the private key and Web3Signer TOML config +// to the host-side PVC path so that Web3Signer can load them on startup. +func ProvisionKeyFiles(keysDir string, key *Web3SignerKey, label string) error { + if err := os.MkdirAll(keysDir, 0755); err != nil { + return fmt.Errorf("failed to create keys directory: %w", err) + } + + // Write Web3Signer YAML key config with the private key inline. + // v25+ scans for .yaml files and expects the private key as a + // 0x-prefixed hex value in the `privateKey` field. + yamlContent := fmt.Sprintf(`type: "file-raw" +keyType: "SECP256K1" +privateKey: "0x%s" +`, key.PrivateKeyHex) + + configFile := filepath.Join(keysDir, key.KeyID+".yaml") + if err := os.WriteFile(configFile, []byte(yamlContent), 0600); err != nil { + return fmt.Errorf("failed to write key config: %w", err) + } + + return nil +} + +// Web3SignerKeysPath returns the host-side directory where Web3Signer +// key files are provisioned. The chart creates a StatefulSet with a PVC +// named "storage-web3signer-0" which the local-path-provisioner maps to +// $DATA_DIR//storage-web3signer-0/ on the host. This path +// appears as /data/ inside the web3signer pod. +func Web3SignerKeysPath(cfg *config.Config, id string) string { + namespace := fmt.Sprintf("%s-%s", appName, id) + return filepath.Join(cfg.DataDir, namespace, "storage-web3signer-0", "keys") +} + +// MetadataAddress represents a single signing address in the ConfigMap. +type MetadataAddress struct { + Address string `json:"address"` + PublicKey string `json:"publicKey"` + CreatedAt string `json:"createdAt"` + Label string `json:"label"` +} + +// MetadataPayload is the JSON structure stored in the web3signer-metadata ConfigMap. +type MetadataPayload struct { + InstanceID string `json:"instanceId"` + Addresses []MetadataAddress `json:"addresses"` + Count int `json:"count"` +} + +// ApplyMetadataConfigMap creates or updates the web3signer-metadata ConfigMap +// in the instance namespace. The frontend reads this for display purposes. +func ApplyMetadataConfigMap(cfg *config.Config, id string, key *Web3SignerKey) error { + namespace := fmt.Sprintf("%s-%s", appName, id) + + payload := MetadataPayload{ + InstanceID: id, + Addresses: []MetadataAddress{ + { + Address: key.Address, + PublicKey: key.PublicKeyHex, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Label: fmt.Sprintf("obol-agent-%s", id), + }, + }, + Count: 1, + } + + payloadJSON, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + // Build ConfigMap YAML + configMapYAML := fmt.Sprintf(`apiVersion: v1 +kind: ConfigMap +metadata: + name: web3signer-metadata + namespace: %s + labels: + app.kubernetes.io/component: web3signer + app.kubernetes.io/managed-by: obol +data: + addresses.json: | + %s +`, namespace, indentJSON(string(payloadJSON), 4)) + + // Apply via kubectl + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + kubectlPath := filepath.Join(cfg.BinDir, "kubectl") + + cmd := exec.Command(kubectlPath, "apply", "-f", "-") + cmd.Env = append(os.Environ(), "KUBECONFIG="+kubeconfigPath) + cmd.Stdin = strings.NewReader(configMapYAML) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to apply web3signer-metadata ConfigMap: %w", err) + } + + return nil +} + +// indentJSON re-indents a JSON string with the given number of leading spaces +// on each line (for embedding in YAML). +func indentJSON(s string, spaces int) string { + prefix := "" + for i := 0; i < spaces; i++ { + prefix += " " + } + result := "" + for i, line := range splitLines(s) { + if i == 0 { + result += line + } else { + result += "\n" + prefix + line + } + } + return result +} + +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} + +// applyWeb3SignerMetadata reads the signing key from the provisioned key files +// and creates the web3signer-metadata ConfigMap. Called after helmfile sync +// when the namespace exists. Errors are non-fatal (printed as warnings). +func applyWeb3SignerMetadata(cfg *config.Config, id string) { + keysDir := Web3SignerKeysPath(cfg, id) + + // Find the .hex key file to reconstruct the key info + entries, err := os.ReadDir(keysDir) + if err != nil { + fmt.Printf(" Warning: could not read web3signer keys directory: %v\n", err) + return + } + + for _, entry := range entries { + if filepath.Ext(entry.Name()) != ".hex" { + continue + } + keyID := strings.TrimSuffix(entry.Name(), ".hex") + + privHex, err := os.ReadFile(filepath.Join(keysDir, entry.Name())) + if err != nil { + fmt.Printf(" Warning: could not read key file: %v\n", err) + continue + } + + privBytes, err := hex.DecodeString(strings.TrimSpace(string(privHex))) + if err != nil { + fmt.Printf(" Warning: invalid key hex: %v\n", err) + continue + } + + // Derive address from private key + privKey := secp256k1.PrivKeyFromBytes(privBytes) + pubBytes := privKey.PubKey().SerializeUncompressed() + + hash := sha3.NewLegacyKeccak256() + hash.Write(pubBytes[1:]) + addrBytes := hash.Sum(nil)[12:] + + key := &Web3SignerKey{ + PublicKeyHex: "0x" + hex.EncodeToString(pubBytes), + Address: "0x" + hex.EncodeToString(addrBytes), + KeyID: keyID, + } + + if err := ApplyMetadataConfigMap(cfg, id, key); err != nil { + fmt.Printf(" Warning: could not create web3signer-metadata ConfigMap: %v\n", err) + } else { + fmt.Printf(" βœ“ Web3Signer metadata published (address: %s)\n", key.Address) + } + return // only process the first key + } +} + +// ensureWeb3Signer checks if the web3signer key and values file exist for +// an existing deployment. If not, it generates them. This handles the case +// where an existing deployment (created before web3signer was added) is +// re-synced β€” the helmfile now references web3signer but the key/values +// haven't been provisioned yet. +func ensureWeb3Signer(cfg *config.Config, id, deploymentDir string) { + valuesPath := filepath.Join(deploymentDir, "values-web3signer.yaml") + keysDir := Web3SignerKeysPath(cfg, id) + + // Check if values file already exists + if _, err := os.Stat(valuesPath); err == nil { + // Values exist β€” check if keys also exist + if entries, err := os.ReadDir(keysDir); err == nil { + for _, e := range entries { + if filepath.Ext(e.Name()) == ".hex" { + return // Both values and key exist β€” nothing to do + } + } + } + } + + // Generate signing key + fmt.Println("\nProvisioning Web3Signer for existing deployment...") + signingKey, err := GenerateSigningKey() + if err != nil { + fmt.Printf(" Warning: could not generate signing key: %v\n", err) + return + } + + keyLabel := fmt.Sprintf("obol-agent-%s", id) + if err := ProvisionKeyFiles(keysDir, signingKey, keyLabel); err != nil { + fmt.Printf(" Warning: could not provision signing key: %v\n", err) + return + } + fmt.Printf(" βœ“ Agent wallet address: %s\n", signingKey.Address) + fmt.Printf(" Back up your key: cp %s/%s.yaml ~/obol-wallet-backup-%s.yaml\n", keysDir, signingKey.KeyID, id) + + // Write values file + web3signerValues := generateWeb3SignerValues(id) + if err := os.WriteFile(valuesPath, []byte(web3signerValues), 0644); err != nil { + fmt.Printf(" Warning: could not write web3signer values: %v\n", err) + return + } + fmt.Println(" βœ“ Web3Signer values written") +} + +// generateWeb3SignerValues creates the values-web3signer.yaml content +// for the Web3Signer Helm release. +func generateWeb3SignerValues(id string) string { + return fmt.Sprintf(`# Web3Signer configuration for OpenClaw instance: %s +# Managed by obol openclaw β€” do not edit manually. + +replicas: 1 + +image: + repository: consensys/web3signer + tag: "%s" + +# Override the default command to use eth1 mode instead of eth2. +# The chart's _cmd.tpl hardcodes "eth2" as the subcommand β€” we need "eth1" +# for SECP256K1 execution-layer signing. +# Override the config template to set data-path to /data/keys so that +# web3signer only scans our key YAML files, not chart's config.yaml. +config: | + data-path: "/data/keys" + http-listen-port: {{ .Values.httpPort }} + http-listen-host: 0.0.0.0 + http-host-allowlist: "*" + +customCommand: + - sh + - -ac + - | + exec /opt/web3signer/bin/web3signer \ + --config-file=/data/config.yaml \ + --key-config-path=/data/keys \ + eth1 --chain-id=1 + +# Key storage via chart's built-in persistence. +# Keys are pre-provisioned by 'obol agent init' to the host-path PVC. +persistence: + enabled: true + size: 100Mi + accessModes: + - ReadWriteOnce + +# Slashing protection DB (PostgreSQL) is not needed for ETH1 file-based keys. +# The chart's dependency condition is 'slashingprotectiondb.enabled'. +slashingprotectiondb: + enabled: false + +# ClusterIP only β€” no external exposure. +service: + type: ClusterIP + +# No ingress β€” web3signer is namespace-internal only. +ingress: + enabled: false + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi +`, id, web3signerImageTag) +} diff --git a/internal/openclaw/web3signer_test.go b/internal/openclaw/web3signer_test.go new file mode 100644 index 0000000..1206b7e --- /dev/null +++ b/internal/openclaw/web3signer_test.go @@ -0,0 +1,271 @@ +package openclaw + +import ( + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/config" +) + +func TestGenerateSigningKey(t *testing.T) { + key, err := GenerateSigningKey() + if err != nil { + t.Fatalf("GenerateSigningKey() error: %v", err) + } + + // Private key should be 64 hex chars (32 bytes) + if len(key.PrivateKeyHex) != 64 { + t.Errorf("PrivateKeyHex length = %d, want 64", len(key.PrivateKeyHex)) + } + if _, err := hex.DecodeString(key.PrivateKeyHex); err != nil { + t.Errorf("PrivateKeyHex is not valid hex: %v", err) + } + + // Public key should be 0x-prefixed, 132 chars (0x + 130 hex = 65 bytes uncompressed) + if !strings.HasPrefix(key.PublicKeyHex, "0x") { + t.Errorf("PublicKeyHex should start with 0x, got: %s", key.PublicKeyHex[:4]) + } + if len(key.PublicKeyHex) != 132 { + t.Errorf("PublicKeyHex length = %d, want 132 (0x + 130 hex chars)", len(key.PublicKeyHex)) + } + + // Address should be 0x-prefixed, 42 chars (0x + 40 hex = 20 bytes) + if !strings.HasPrefix(key.Address, "0x") { + t.Errorf("Address should start with 0x, got: %s", key.Address[:4]) + } + if len(key.Address) != 42 { + t.Errorf("Address length = %d, want 42", len(key.Address)) + } + + // KeyID should be 8 hex chars (4 bytes) + if len(key.KeyID) != 8 { + t.Errorf("KeyID length = %d, want 8", len(key.KeyID)) + } +} + +func TestGenerateSigningKey_Unique(t *testing.T) { + key1, err := GenerateSigningKey() + if err != nil { + t.Fatalf("first GenerateSigningKey() error: %v", err) + } + key2, err := GenerateSigningKey() + if err != nil { + t.Fatalf("second GenerateSigningKey() error: %v", err) + } + + if key1.PrivateKeyHex == key2.PrivateKeyHex { + t.Error("two generated keys should not have the same private key") + } + if key1.Address == key2.Address { + t.Error("two generated keys should not have the same address") + } + if key1.KeyID == key2.KeyID { + t.Error("two generated keys should not have the same key ID") + } +} + +func TestProvisionKeyFiles(t *testing.T) { + dir := t.TempDir() + + key := &Web3SignerKey{ + PrivateKeyHex: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + PublicKeyHex: "0x04" + strings.Repeat("ab", 64), + Address: "0x" + strings.Repeat("cd", 20), + KeyID: "testkey1", + } + + err := ProvisionKeyFiles(dir, key, "test-agent") + if err != nil { + t.Fatalf("ProvisionKeyFiles() error: %v", err) + } + + // Check .yaml key config file exists with inline private key + yamlFile := filepath.Join(dir, "testkey1.yaml") + info, err := os.Stat(yamlFile) + if err != nil { + t.Fatalf("failed to stat yaml key config: %v", err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("yaml file permissions = %o, want 0600", perm) + } + + yamlContent, err := os.ReadFile(yamlFile) + if err != nil { + t.Fatalf("failed to read yaml key config: %v", err) + } + yaml := string(yamlContent) + if !strings.Contains(yaml, `type: "file-raw"`) { + t.Error("yaml should contain type: file-raw") + } + if !strings.Contains(yaml, `privateKey: "0x`+key.PrivateKeyHex+`"`) { + t.Error("yaml should contain inline private key with 0x prefix") + } + if !strings.Contains(yaml, `keyType: "SECP256K1"`) { + t.Error("yaml should specify SECP256K1 key type") + } +} + +func TestProvisionKeyFiles_CreatesDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "keys") + + key := &Web3SignerKey{ + PrivateKeyHex: strings.Repeat("ab", 32), + KeyID: "k1", + } + + err := ProvisionKeyFiles(dir, key, "test") + if err != nil { + t.Fatalf("ProvisionKeyFiles() should create nested dirs: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, "k1.yaml")); os.IsNotExist(err) { + t.Error("key config not created in nested directory") + } +} + +func TestGenerateWeb3SignerValues(t *testing.T) { + values := generateWeb3SignerValues("my-agent") + + // Should contain the instance ID + if !strings.Contains(values, "my-agent") { + t.Error("values should reference the instance ID") + } + + // Should use customCommand with eth1 subcommand (not the chart's default eth2) + if !strings.Contains(values, "customCommand:") { + t.Error("values should use customCommand to override default eth2 command") + } + if !strings.Contains(values, "eth1") { + t.Error("values should use eth1 subcommand") + } + + // Should disable slashing protection DB (PostgreSQL) + if !strings.Contains(values, "slashingprotectiondb:") || !strings.Contains(values, "enabled: false") { + t.Error("values should disable slashingprotectiondb (PostgreSQL)") + } + + // Should use ClusterIP service + if !strings.Contains(values, "type: ClusterIP") { + t.Error("values should use ClusterIP service type") + } + + // Should pin the image tag + if !strings.Contains(values, web3signerImageTag) { + t.Errorf("values should pin image tag %s", web3signerImageTag) + } + + // Should disable ingress + if !strings.Contains(values, "ingress:") { + t.Error("values should have ingress section") + } +} + +func TestMetadataPayload_JSON(t *testing.T) { + payload := MetadataPayload{ + InstanceID: "test-id", + Addresses: []MetadataAddress{ + { + Address: "0x1234567890abcdef1234567890abcdef12345678", + PublicKey: "0x04abcd", + CreatedAt: "2026-02-20T14:30:00Z", + Label: "obol-agent-test-id", + }, + }, + Count: 1, + } + + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal metadata: %v", err) + } + + var decoded MetadataPayload + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("failed to unmarshal metadata: %v", err) + } + + if decoded.InstanceID != "test-id" { + t.Errorf("InstanceID = %q, want %q", decoded.InstanceID, "test-id") + } + if decoded.Count != 1 { + t.Errorf("Count = %d, want 1", decoded.Count) + } + if len(decoded.Addresses) != 1 { + t.Fatalf("Addresses length = %d, want 1", len(decoded.Addresses)) + } + if decoded.Addresses[0].Address != "0x1234567890abcdef1234567890abcdef12345678" { + t.Errorf("Address = %q, want full address", decoded.Addresses[0].Address) + } +} + +func TestGenerateHelmfile_IncludesWeb3Signer(t *testing.T) { + helmfile := generateHelmfile("my-id", "openclaw-my-id") + + // Should have both repos + if !strings.Contains(helmfile, "name: obol") { + t.Error("helmfile should have obol repo") + } + if !strings.Contains(helmfile, "name: ethereum") { + t.Error("helmfile should have ethereum repo") + } + if !strings.Contains(helmfile, "ethpandaops.github.io/ethereum-helm-charts") { + t.Error("helmfile should reference ethpandaops helm charts") + } + + // Should have both releases + if !strings.Contains(helmfile, "name: openclaw") { + t.Error("helmfile should have openclaw release") + } + if !strings.Contains(helmfile, "name: web3signer") { + t.Error("helmfile should have web3signer release") + } + + // Both releases should target the same namespace + if strings.Count(helmfile, "namespace: openclaw-my-id") != 2 { + t.Error("both releases should target the same namespace openclaw-my-id") + } + + // Should reference web3signer values file + if !strings.Contains(helmfile, "values-web3signer.yaml") { + t.Error("helmfile should reference values-web3signer.yaml") + } + + // Should reference the pinned chart version + if !strings.Contains(helmfile, web3signerChartVersion) { + t.Errorf("helmfile should pin web3signer chart version %s", web3signerChartVersion) + } +} + +func TestWeb3SignerKeysPath(t *testing.T) { + cfg := &config.Config{ + DataDir: "/home/user/.local/share/obol", + } + + path := Web3SignerKeysPath(cfg, "my-agent") + expected := "/home/user/.local/share/obol/openclaw-my-agent/storage-web3signer-0/keys" + if path != expected { + t.Errorf("Web3SignerKeysPath() = %q, want %q", path, expected) + } +} + +func TestIndentJSON(t *testing.T) { + input := `{ + "foo": "bar", + "baz": 1 +}` + result := indentJSON(input, 4) + lines := strings.Split(result, "\n") + + // First line should not be indented (already at right level in YAML) + if lines[0] != "{" { + t.Errorf("first line should be '{', got %q", lines[0]) + } + // Subsequent lines should have 4-space indent + if !strings.HasPrefix(lines[1], " ") { + t.Errorf("second line should have 4-space indent, got %q", lines[1]) + } +} diff --git a/internal/stack/backend_k3d.go b/internal/stack/backend_k3d.go index 8fdd3de..0bc1f2f 100644 --- a/internal/stack/backend_k3d.go +++ b/internal/stack/backend_k3d.go @@ -1,16 +1,25 @@ package stack import ( + "crypto/tls" "fmt" + "net/http" "os" "os/exec" "path/filepath" "strings" + "time" "github.com/ObolNetwork/obol-stack/internal/config" "github.com/ObolNetwork/obol-stack/internal/embed" ) +// tlsInsecureSkipVerify returns a TLS config that skips certificate verification. +// Used only for health-checking the local k3s API server which uses a self-signed cert. +func tlsInsecureSkipVerify() *tls.Config { + return &tls.Config{InsecureSkipVerify: true} //nolint:gosec // local k3s health check only +} + const ( k3dConfigFile = "k3d.yaml" ) @@ -121,9 +130,67 @@ func (b *K3dBackend) Up(cfg *config.Config, stackID string) ([]byte, error) { return nil, fmt.Errorf("failed to get kubeconfig: %w", err) } + // k3d generates kubeconfig with server: https://0.0.0.0:. + // On macOS, Go's HTTP client and helm can't connect to 0.0.0.0. + // Replace with 127.0.0.1 which works on all platforms. + kubeconfigData = []byte(strings.ReplaceAll(string(kubeconfigData), "https://0.0.0.0:", "https://127.0.0.1:")) + + // Wait for the Kubernetes API server to be reachable. + // After k3d starts containers, k3s inside needs time to bind ports. + if err := waitForAPIServer(kubeconfigData); err != nil { + return nil, fmt.Errorf("cluster started but API server not ready: %w", err) + } + return kubeconfigData, nil } +// waitForAPIServer polls the Kubernetes API server URL from the kubeconfig +// until it responds or a timeout is reached. This prevents race conditions +// where helmfile runs before k3s has bound its listener. +func waitForAPIServer(kubeconfigData []byte) error { + // Extract the server URL from kubeconfig (e.g. https://0.0.0.0:52489) + var serverURL string + for _, line := range strings.Split(string(kubeconfigData), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "server:") { + serverURL = strings.TrimSpace(strings.TrimPrefix(trimmed, "server:")) + break + } + } + if serverURL == "" { + return fmt.Errorf("could not find server URL in kubeconfig") + } + + // k3d kubeconfig uses 0.0.0.0 which doesn't work with Go's HTTP client + // on macOS (can't connect to 0.0.0.0). Replace with 127.0.0.1. + serverURL = strings.Replace(serverURL, "0.0.0.0", "127.0.0.1", 1) + + // k3s uses a self-signed cert, so skip TLS verification for the health check + client := &http.Client{ + Timeout: 2 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: tlsInsecureSkipVerify(), + }, + } + + fmt.Print("Waiting for Kubernetes API server...") + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + resp, err := client.Get(serverURL + "/version") + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized { + fmt.Println(" ready") + return nil + } + } + time.Sleep(2 * time.Second) + fmt.Print(".") + } + + return fmt.Errorf("timed out after 60s waiting for API server at %s", serverURL) +} + func (b *K3dBackend) Down(cfg *config.Config, stackID string) error { stackName := fmt.Sprintf("obol-stack-%s", stackID) diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 52bb177..9ea2693 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -218,7 +218,7 @@ func Up(cfg *config.Config) error { return nil } -// Down stops the cluster +// Down stops the cluster and the DNS resolver container. func Down(cfg *config.Config) error { stackID := getStackID(cfg) if stackID == "" { @@ -230,6 +230,10 @@ func Down(cfg *config.Config) error { return fmt.Errorf("failed to load backend: %w", err) } + // Stop the DNS resolver container so it doesn't hold port 5553 + // across restarts and block subsequent obol stack up runs. + dns.Stop() + return backend.Down(cfg, stackID) } diff --git a/obolup.sh b/obolup.sh index d965967..f4d0f67 100755 --- a/obolup.sh +++ b/obolup.sh @@ -1382,6 +1382,7 @@ print_instructions() { echo "" echo " obol stack init" echo " obol stack up" + echo " obol agent init" echo "" return 1 fi diff --git a/plans/ethereum-wallet-web3signer.md b/plans/ethereum-wallet-web3signer.md new file mode 100644 index 0000000..6c9cb99 --- /dev/null +++ b/plans/ethereum-wallet-web3signer.md @@ -0,0 +1,763 @@ +# Ethereum Wallet: Web3Signer Integration Plan + +> **Status**: Draft v2 β€” awaiting review before implementation +> **Date**: 2026-02-20 +> **Scope**: Deploy Web3Signer per OpenClaw instance, generate signing key at init time, flesh out the `ethereum-wallet` skill, expose public keys to frontend read-only + +--- + +## 1. Overview + +Add transaction signing capabilities to OpenClaw agents by deploying a [Web3Signer](https://docs.web3signer.consensys.io/) instance alongside each OpenClaw deployment. A SECP256K1 signing key is generated at `obol agent init` time by the `obol` CLI (not by OpenClaw). The `ethereum-wallet` skill gives the agent HTTP-only access to sign and submit transactions β€” it never touches private key material. + +### Design Principles + +1. **Key generation is infrastructure, not agent behavior** β€” the `obol` CLI generates keys at init time and provisions them into web3signer's volume. OpenClaw never creates, reads, or manages private keys. +2. **Separate volumes** β€” web3signer owns its key PVC exclusively. OpenClaw has no mount to the keys directory. +3. **ClusterIP isolation** β€” web3signer has no HTTPRoute, no external exposure. It's a ClusterIP service reachable only within the namespace. +4. **Sign via web3signer, submit via eRPC** β€” `eth_signTransaction` returns RLP-encoded signed tx data. The skill submits it via `eth_sendRawTransaction` on eRPC with the appropriate `--network` path. +5. **Public key metadata via ConfigMap** β€” the frontend reads a ConfigMap for display purposes without any access to signing operations. + +### What Changes + +| Area | Change | +|------|--------| +| `internal/openclaw/openclaw.go` | `generateHelmfile()` adds `web3signer` release in the same namespace | +| `internal/openclaw/web3signer.go` (new) | Key generation, values generation, ConfigMap creation | +| `internal/openclaw/openclaw.go` | `generateOverlayValues()` adds `WEB3SIGNER_URL` env var | +| `internal/embed/skills/ethereum-wallet/` | Full skill: `SKILL.md`, `scripts/signer.py`, `references/web3signer-api.md` | + +### What Doesn't Change + +- `obol agent init` still calls `openclaw.Onboard()` β€” no new top-level CLI commands +- Stack infrastructure (`obol stack init/up`) β€” web3signer is per-instance, not cluster-wide +- Existing skills β€” `ethereum-networks` remains read-only, `obol-stack` and `distributed-validators` unchanged +- OpenClaw chart β€” no new volume mounts needed (skill only uses HTTP) + +--- + +## 2. Architecture + +### Deployment Topology + +``` +Namespace: openclaw- +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ OpenClaw Pod β”‚ HTTP β”‚ Web3Signer Pod β”‚ β”‚ +β”‚ β”‚ │────────────▢│ β”‚ β”‚ +β”‚ β”‚ skills/ β”‚ :9000 β”‚ /data/keys/ β”‚ β”‚ +β”‚ β”‚ ethereum- β”‚ (JSON-RPC) β”‚ .hex β”‚ β”‚ +β”‚ β”‚ wallet/ β”‚ β”‚ .toml β”‚ β”‚ +β”‚ β”‚ scripts/ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ signer.py β”‚ β”‚ No external β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ route exposed β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ web3signer PVC β”‚ β”‚ +β”‚ β”‚ (keys only β€” β”‚ β”‚ +β”‚ β”‚ NOT shared) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ ConfigMap: web3signer-metadata β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ addresses.json: β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ { "addresses": [{"address":"0x...", β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ "publicKey":"0x...", "createdAt":"..."}], β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ "count": 1 } β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–² readable by frontend (existing ClusterRole) β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ eth_sendRawTransaction + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ eRPC β”‚ (namespace: erpc) + β”‚ :4000/rpc β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Data Flow: Sign & Send Transaction + +``` +1. Agent decides to send ETH +2. Skill script: GET /api/v1/eth1/publicKeys β†’ web3signer β†’ signer public key +3. Skill script: eth_accounts (JSON-RPC) β†’ web3signer β†’ signer address +4. Skill script: eth_getTransactionCount β†’ eRPC β†’ nonce +5. Skill script: eth_gasPrice + eth_estimateGas β†’ eRPC β†’ gas params +6. Skill script: eth_signTransaction (JSON-RPC) β†’ web3signer β†’ RLP-encoded signed tx +7. Skill script: eth_sendRawTransaction β†’ eRPC β†’ tx hash +8. Skill script: eth_getTransactionReceipt β†’ eRPC β†’ confirmation +``` + +Key point: `eth_signTransaction` on web3signer returns the RLP-encoded signed transaction directly. No RLP encoding needed in Python. The skill submits it to eRPC as-is via `eth_sendRawTransaction`. + +### Data Flow: Key Generation (at init time) + +``` +1. obol agent init +2. Go code: crypto/ecdsa.GenerateKey(secp256k1) β†’ private key +3. Go code: derive public key β†’ derive Ethereum address (keccak256) +4. Go code: write private key hex β†’ $DATA_DIR/openclaw-/web3signer-keys/.hex +5. Go code: write TOML config β†’ $DATA_DIR/openclaw-/web3signer-keys/.toml +6. Go code: create ConfigMap web3signer-metadata with address + public key +7. helmfile sync β†’ deploys web3signer with PVC mounted at key directory +8. web3signer starts β†’ reads key files β†’ ready to sign +``` + +--- + +## 3. Web3Signer Deployment + +### 3.1 Helmfile Integration + +Modify `generateHelmfile()` in `internal/openclaw/openclaw.go` to produce a multi-release helmfile: + +```yaml +# OpenClaw instance: +# Managed by obol openclaw + +repositories: + - name: obol + url: https://obolnetwork.github.io/helm-charts/ + - name: ethereum + url: https://ethpandaops.github.io/ethereum-helm-charts + +releases: + - name: openclaw + namespace: openclaw- + createNamespace: true + chart: obol/openclaw + version: 0.1.4 + values: + - values-obol.yaml + + - name: web3signer + namespace: openclaw- + chart: ethereum/web3signer + version: 1.0.6 + values: + - values-web3signer.yaml +``` + +### 3.2 Web3Signer Values (`values-web3signer.yaml`) + +Generated by `generateWeb3SignerValues()` in a new `internal/openclaw/web3signer.go`: + +```yaml +# Web3Signer configuration for OpenClaw instance +replicas: 1 + +image: + repository: consensys/web3signer + tag: "24.12.1" # Pin a specific stable version + +# ETH1 signing mode β€” keys loaded from /data/keys/ +extraArgs: + - "--eth1-enabled" + - "--key-store-path=/data/keys" + - "--http-host-allowlist=*" + +# Use the chart's built-in persistence for key storage +# Keys are pre-provisioned by `obol agent init` via host-path PVC +persistence: + enabled: true + size: 100Mi + accessModes: + - ReadWriteOnce + +# Disable PostgreSQL β€” not needed for ETH1 file-based keys +postgresql: + enabled: false + +# Service β€” ClusterIP only, no external exposure +service: + type: ClusterIP + +# No ingress β€” web3signer is namespace-internal only +ingress: + enabled: false + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi +``` + +### 3.3 Key Pre-Provisioning via Host-Path + +Keys are written to the host filesystem before web3signer starts, using the same host-path PVC pattern as skills injection: + +``` +Host path: $DATA_DIR/openclaw-/web3signer-data/keys/ + ↓ (k3d volume mount + local-path-provisioner) +Pod path: /data/keys/ +``` + +The Go code in `web3signer.go` writes key files to the host-side path at init time. When the web3signer pod starts, the PVC is already populated. + +**File layout on host**: +``` +$DATA_DIR/openclaw-/web3signer-data/keys/ +β”œβ”€β”€ .hex # Raw private key (64 hex chars) +└── .toml # Web3Signer key config +``` + +**TOML key config format**: +```toml +[metadata] +description = "obol-agent-" + +[signing] +type = "file-raw" +filename = "/data/keys/.hex" +``` + +### 3.4 Key Generation in Go + +New function in `internal/openclaw/web3signer.go`: + +```go +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/hex" + + "golang.org/x/crypto/sha3" +) + +func generateSigningKey() (privateKeyHex, publicKeyHex, address string, err error) { + // Generate SECP256K1 key + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + // NOTE: Go stdlib doesn't have secp256k1. Options: + // a) Use github.com/ethereum/go-ethereum/crypto (already used?) + // b) Use github.com/decred/dcrd/dcrec/secp256k1 + // c) Use P256 for dev, swap to secp256k1 before production + + // Derive Ethereum address: keccak256(uncompressed_pubkey[1:])[12:] + pubBytes := elliptic.Marshal(key.Curve, key.PublicKey.X, key.PublicKey.Y) + hash := sha3.NewLegacyKeccak256() + hash.Write(pubBytes[1:]) // skip 0x04 prefix + addr := hash.Sum(nil)[12:] + + return hex.EncodeToString(key.D.Bytes()), + hex.EncodeToString(pubBytes), + "0x" + hex.EncodeToString(addr), + nil +} +``` + +**Note on secp256k1**: Go's `crypto/elliptic` has P256 but NOT secp256k1. We need one of: +- `github.com/ethereum/go-ethereum/crypto` β€” the standard Go-Ethereum library (may already be a transitive dep) +- `github.com/decred/dcrd/dcrec/secp256k1/v4` β€” lightweight standalone +- `github.com/btcsuite/btcd/btcec/v2` β€” Bitcoin library with secp256k1 + +Recommendation: use `go-ethereum/crypto` if it's already in the dep tree, otherwise `decred/secp256k1` is the lightest option. Check `go.mod` during implementation. + +### 3.5 ConfigMap for Frontend (Public Key Metadata) + +Created by Go code at init time, in the instance namespace: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: web3signer-metadata + namespace: openclaw- + labels: + app.kubernetes.io/component: web3signer + app.kubernetes.io/managed-by: obol +data: + addresses.json: | + { + "instanceId": "", + "addresses": [ + { + "address": "0xAbCd...1234", + "publicKey": "0x04...", + "createdAt": "2026-02-20T14:30:00Z", + "label": "obol-agent-" + } + ], + "count": 1 + } +``` + +**Frontend reads this via**: the existing `obol-frontend-rbac` ClusterRole which grants read access to ConfigMaps across namespaces. The frontend can list ConfigMaps with label `app.kubernetes.io/component: web3signer` across all `openclaw-*` namespaces to discover all agents' addresses. + +**Multiple OpenClaw instances**: Each instance gets its own `web3signer-metadata` ConfigMap in its own namespace. The frontend aggregates them: + +``` +openclaw-alpha/ β†’ web3signer-metadata (address: 0xABC...) +openclaw-beta/ β†’ web3signer-metadata (address: 0xDEF...) +``` + +**Update mechanism**: If keys are ever added later (e.g., via a future `obol web3signer add-key` CLI command), the ConfigMap is patched via `kubectl apply`. The skill script never updates this ConfigMap β€” that's the CLI's responsibility. + +--- + +## 4. Security Model + +### 4.1 ClusterIP Isolation (Primary) + +Web3Signer is deployed as a ClusterIP service with **no HTTPRoute**. It's unreachable from outside the cluster and from external Traefik routing. + +``` +Accessible: http://web3signer.openclaw-.svc.cluster.local:9000 +Not exposed: No HTTPRoute, no Ingress, no NodePort, no LoadBalancer +``` + +### 4.2 No Bearer Token (Simplified) + +Web3Signer ETH1 mode does not have built-in bearer token auth (that's a keymanager/ETH2 feature). Rather than adding an auth proxy sidecar, we accept ClusterIP isolation as sufficient for a local development stack. + +**Rationale**: +- The k3d cluster runs on localhost β€” there's no network path from the internet to web3signer +- No HTTPRoute means Traefik doesn't route any external traffic to it +- Flannel's default behavior still requires being in-cluster to reach ClusterIP services +- The keys are for development/testing, not production custody + +### 4.3 Separation of Concerns + +| Actor | Can do | Cannot do | +|-------|--------|-----------| +| `obol` CLI | Generate keys, write to PVC, create ConfigMap, deploy web3signer | N/A (full control) | +| OpenClaw skill | Call JSON-RPC signing endpoints via HTTP | Read key files, create keys, access PVC | +| Frontend | Read `web3signer-metadata` ConfigMap (public keys, counts) | Call web3signer API, read secrets, modify ConfigMap | +| Other namespaces | Nothing | Reach web3signer service (ClusterIP is namespace-scoped for practical purposes) | + +### 4.4 Future Hardening (Out of Scope) + +If stronger isolation is needed later: +- Switch k3s CNI to Calico for NetworkPolicy enforcement +- Add an nginx auth proxy sidecar with shared-secret bearer token +- Move to encrypted keystores (V3) with password-protected keys +- Add RBAC restricting which ServiceAccounts can exec into the web3signer pod + +--- + +## 5. Ethereum Wallet Skill + +### 5.1 Skill Structure + +``` +internal/embed/skills/ethereum-wallet/ +β”œβ”€β”€ SKILL.md # Full skill documentation +β”œβ”€β”€ scripts/ +β”‚ └── signer.py # Python 3 stdlib-only HTTP client +└── references/ + └── web3signer-api.md # ETH1 API quick-reference +``` + +### 5.2 SKILL.md Content + +```markdown +--- +name: ethereum-wallet +description: "Sign and send Ethereum transactions via the local Web3Signer. + Use when asked to send ETH, sign messages, or interact with contracts + that modify state." +metadata: + openclaw: + emoji: "πŸ”" + requires: + bins: ["python3"] +--- + +# Ethereum Wallet + +Sign and send Ethereum transactions through the local Web3Signer instance. +Keys are pre-generated during setup β€” this skill signs and submits only. + +## When to Use + +- Listing available signing addresses (wallets) +- Sending ETH to an address +- Signing messages or typed data (EIP-712) +- Signing transactions for later broadcast +- Calling contract functions that modify state (write operations) + +## When NOT to Use + +- Reading blockchain data (balances, blocks, transactions) β€” use `ethereum-networks` +- Creating new keys β€” keys are managed by the `obol` CLI, not this skill +- Monitoring validators β€” use `distributed-validators` +- Kubernetes diagnostics β€” use `obol-stack` + +## Quick Start + +# List signing addresses +python3 scripts/signer.py accounts + +# Check web3signer health +python3 scripts/signer.py health + +# Sign a message +python3 scripts/signer.py sign
+ +# Sign a transaction (returns signed raw tx hex) +python3 scripts/signer.py sign-tx \ + --from
--to
--value 1000000000000000000 + +# Sign AND submit a transaction +python3 scripts/signer.py send-tx \ + --from
--to
--value 1000000000000000000 + +# Sign EIP-712 typed data +python3 scripts/signer.py sign-typed
'{"types":...}' + +## Available Commands + +| Command | Params | Description | +|---------|--------|-------------| +| `accounts` | none | List signing addresses from web3signer | +| `health` | none | Check web3signer /upcheck | +| `sign` | `address data` | Sign arbitrary hex data (eth_sign) | +| `sign-tx` | `--from --to [--value] [--data] [--gas] [--nonce] [--network]` | Sign a tx, return raw signed hex | +| `sign-typed` | `address typed-data-json` | Sign EIP-712 typed data | +| `send-tx` | `--from --to [--value] [--data] [--network]` | Sign AND broadcast via eRPC | + +## Transaction Submission Flow + +`send-tx` does the following: +1. Fetches nonce, gas price, chain ID from eRPC (unless provided) +2. Calls `eth_signTransaction` on web3signer β†’ gets RLP-encoded signed tx +3. Calls `eth_sendRawTransaction` on eRPC β†’ gets tx hash +4. Reports the tx hash (use `ethereum-networks` to check receipt) + +## Multi-Network Support + +By default, transactions target `mainnet`. Use `--network` to change: + +python3 scripts/signer.py send-tx --network hoodi \ + --from 0x... --to 0x... --value 1000000000000000000 + +The signing key is chain-agnostic β€” the same address works on any EVM network. +Network routing goes through eRPC at /rpc/{network}. + +## Constraints + +- **Shell is `sh`, not `bash`** β€” POSIX-compatible syntax only +- **Python stdlib only** β€” no web3, eth_abi, or third-party packages +- **No key creation** β€” keys are managed by `obol` CLI. If no keys exist, tell the user to run `obol agent init` +- **Local only** β€” always use the in-cluster web3signer at $WEB3SIGNER_URL +- **Values in wei** β€” `--value` is in wei (1 ETH = 1000000000000000000). The script does NOT auto-convert from ETH +- **Always check for null** β€” RPC responses may be null; always validate before accessing fields +- **Confirm before sending** β€” always show the user what will be signed before executing `send-tx` +``` + +### 5.3 `scripts/signer.py` Design + +Python 3 stdlib-only script. HTTP client for Web3Signer JSON-RPC + eRPC. + +```python +""" +Ethereum wallet operations via local Web3Signer. + +Environment variables: + WEB3SIGNER_URL β€” default: http://web3signer:9000 + ERPC_URL β€” default: http://erpc.erpc.svc.cluster.local:4000/rpc + ERPC_NETWORK β€” default: mainnet +""" + +import json, os, sys +from urllib.request import Request, urlopen +from urllib.error import HTTPError, URLError + +WEB3SIGNER_URL = os.environ.get("WEB3SIGNER_URL", "http://web3signer:9000") +ERPC_BASE = os.environ.get("ERPC_URL", "http://erpc.erpc.svc.cluster.local:4000/rpc") +ERPC_NETWORK = os.environ.get("ERPC_NETWORK", "mainnet") +``` + +**Commands β€” no filesystem access, HTTP only**: + +| Command | Web3Signer Call | eRPC Call | Notes | +|---------|----------------|-----------|-------| +| `accounts` | `GET /api/v1/eth1/publicKeys` | β€” | Derives addresses from public keys. Or uses `eth_accounts` JSON-RPC | +| `health` | `GET /upcheck` | β€” | Returns "OK" or error | +| `sign` | `eth_sign` JSON-RPC | β€” | `[address, data]` β†’ signature | +| `sign-tx` | `eth_signTransaction` JSON-RPC | `eth_getTransactionCount`, `eth_gasPrice`, `eth_estimateGas`, `eth_chainId` | Auto-fills missing nonce/gas/chainId from eRPC. Returns RLP-encoded signed tx | +| `sign-typed` | `eth_signTypedData` JSON-RPC | β€” | `[address, typedData]` β†’ signature | +| `send-tx` | `eth_signTransaction` JSON-RPC | Same as sign-tx + `eth_sendRawTransaction` | Signs then submits. Reports tx hash | + +**Helper functions**: + +```python +def web3signer_rpc(method, params): + """JSON-RPC call to Web3Signer.""" + payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1} + req = Request(WEB3SIGNER_URL, json.dumps(payload).encode(), + {"Content-Type": "application/json"}) + resp = json.load(urlopen(req)) + if "error" in resp: + print(f"Error: {resp['error'].get('message', resp['error'])}", file=sys.stderr) + sys.exit(1) + return resp.get("result") + +def erpc_rpc(method, params, network=None): + """JSON-RPC call to eRPC.""" + url = f"{ERPC_BASE}/{network or ERPC_NETWORK}" + payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1} + req = Request(url, json.dumps(payload).encode(), + {"Content-Type": "application/json"}) + resp = json.load(urlopen(req)) + if "error" in resp: + print(f"Error: {resp['error'].get('message', resp['error'])}", file=sys.stderr) + sys.exit(1) + return resp.get("result") + +def web3signer_rest(method, path): + """REST API call to Web3Signer.""" + req = Request(f"{WEB3SIGNER_URL}{path}") + req.method = method + return json.load(urlopen(req)) +``` + +**`send-tx` implementation sketch**: + +```python +def send_tx(from_addr, to_addr, value="0x0", data="0x", gas=None, nonce=None, network=None): + net = network or ERPC_NETWORK + + # Auto-fill from eRPC + if nonce is None: + nonce = erpc_rpc("eth_getTransactionCount", [from_addr, "pending"], net) + if gas is None: + gas = erpc_rpc("eth_estimateGas", [{"from": from_addr, "to": to_addr, + "value": value, "data": data}], net) + gas_price = erpc_rpc("eth_gasPrice", [], net) + chain_id = erpc_rpc("eth_chainId", [], net) + + tx = { + "from": from_addr, "to": to_addr, "value": value, + "data": data, "gas": gas, "gasPrice": gas_price, + "nonce": nonce, "chainId": chain_id + } + + # Sign via web3signer + signed = web3signer_rpc("eth_signTransaction", [tx]) + + # Submit via eRPC + tx_hash = erpc_rpc("eth_sendRawTransaction", [signed], net) + print(f"Transaction submitted: {tx_hash}") + return tx_hash +``` + +### 5.4 `references/web3signer-api.md` + +Quick-reference for the ETH1 API surface: + +```markdown +# Web3Signer ETH1 API Reference + +Base URL: $WEB3SIGNER_URL (default: http://web3signer:9000) + +## JSON-RPC Methods + +All methods use POST to the base URL with Content-Type: application/json. + +| Method | Params | Returns | Description | +|--------|--------|---------|-------------| +| `eth_accounts` | `[]` | `["0x..."]` | List signer addresses | +| `eth_sign` | `[address, data]` | `"0x..."` (signature) | Sign with Ethereum prefix | +| `eth_signTransaction` | `[{from,to,gas,gasPrice,value,data,nonce}]` | `"0x..."` (signed RLP) | Sign tx for later broadcast | +| `eth_signTypedData` | `[address, typedData]` | `"0x..."` (signature) | EIP-712 typed data signing | + +## REST API Endpoints + +| Method | Path | Description | Response | +|--------|------|-------------|----------| +| GET | `/upcheck` | Health check | `"OK"` (200) | +| GET | `/api/v1/eth1/publicKeys` | List public keys | `["0x..."]` | +| POST | `/api/v1/eth1/sign/{pubkey}` | Sign raw data | signature hex | +| POST | `/reload` | Reload key configs | 202 Accepted | + +## Transaction Object Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `from` | yes | Signer address | +| `to` | yes* | Recipient (* omit for contract deploy) | +| `value` | no | Wei to send (hex) | +| `data` | no | Calldata (hex) | +| `gas` | no | Gas limit (hex) | +| `gasPrice` | no | Gas price (hex) | +| `maxFeePerGas` | no | EIP-1559 max fee (hex) | +| `maxPriorityFeePerGas` | no | EIP-1559 priority fee (hex) | +| `nonce` | no | Sender nonce (hex) | + +## Error Responses + +| Code | Meaning | +|------|---------| +| 400 | Bad request (malformed params) | +| 404 | Public key not found in keystore | +| 500 | Internal server error | +``` + +--- + +## 6. Implementation Phases + +### Phase 1: Key Generation & Web3Signer Deployment + +**New file**: `internal/openclaw/web3signer.go` + +**Functions**: +- `generateSigningKey() (privateKeyHex, publicKeyHex, address string, err error)` β€” uses `crypto/ecdsa` with secp256k1 curve +- `provisionKeyFiles(dataDir, id, privateKeyHex string) error` β€” writes `.hex` + `.toml` to host-path PVC location +- `generateWeb3SignerValues(id string) string` β€” produces `values-web3signer.yaml` +- `createMetadataConfigMap(cfg, id, address, publicKey string) error` β€” applies `web3signer-metadata` ConfigMap via kubectl +- `web3signerKeysPath(cfg, id string) string` β€” returns `$DATA_DIR/openclaw-/web3signer-data/keys/` + +**Modify**: `internal/openclaw/openclaw.go` +- `generateHelmfile()` β€” add `ethereum` repo and `web3signer` release +- `Onboard()` β€” call key generation + provisioning before `doSync()` +- `generateOverlayValues()` β€” add `WEB3SIGNER_URL` env var to OpenClaw pod config + +**Acceptance criteria**: +- `obol agent init` generates a key and deploys web3signer in the same namespace +- `GET /upcheck` returns `OK` from within the openclaw pod +- `eth_accounts` JSON-RPC returns the generated address +- `web3signer-metadata` ConfigMap exists with correct address data +- Web3signer is NOT accessible via any HTTPRoute + +### Phase 2: Skill Script β€” Accounts & Signing + +**New files**: +- `internal/embed/skills/ethereum-wallet/scripts/signer.py` +- `internal/embed/skills/ethereum-wallet/references/web3signer-api.md` + +**Commands to implement**: +1. `accounts` β€” `eth_accounts` JSON-RPC β†’ list addresses +2. `health` β€” `GET /upcheck` +3. `sign` β€” `eth_sign` JSON-RPC +4. `sign-typed` β€” `eth_signTypedData` JSON-RPC + +**Acceptance criteria**: +- `python3 scripts/signer.py accounts` lists the pre-generated address +- `python3 scripts/signer.py sign 0xdeadbeef` returns a valid signature +- `python3 scripts/signer.py health` returns OK + +### Phase 3: Transaction Signing & Submission + +**Modify**: `scripts/signer.py` + +**Commands to implement**: +1. `sign-tx` β€” build tx, auto-fill from eRPC, `eth_signTransaction` β†’ return signed hex +2. `send-tx` β€” `sign-tx` + `eth_sendRawTransaction` on eRPC +3. `--network` flag support for multi-chain + +**Acceptance criteria**: +- `sign-tx` returns RLP-encoded signed transaction +- `send-tx` submits to eRPC and returns tx hash +- Missing nonce/gas/chainId are auto-fetched from eRPC +- `--network hoodi` routes through eRPC at `/rpc/hoodi` +- Errors (insufficient funds, bad nonce) produce clear messages + +### Phase 4: Skill Documentation & Polish + +**Modify**: +- `internal/embed/skills/ethereum-wallet/SKILL.md` β€” full rewrite (replace placeholder) +- `internal/embed/skills/ethereum-networks/SKILL.md` β€” add cross-reference to wallet skill for write operations + +**New tests**: +- `internal/openclaw/web3signer_test.go` β€” unit tests for key generation, values generation, TOML format +- Integration test in `internal/openclaw/integration_test.go` β€” full deploy + sign flow + +**Acceptance criteria**: +- All unit tests pass +- Integration test deploys web3signer, signs a message, verifies signature +- SKILL.md is complete and follows existing skill patterns +- `ethereum-networks` SKILL.md cross-references wallet skill + +--- + +## 7. Open Questions (Resolved) + +| # | Question | Decision | Rationale | +|---|----------|----------|-----------| +| Q1 | Transaction submission | `eth_signTransaction` β†’ `eth_sendRawTransaction` via eRPC | Web3Signer returns RLP-encoded signed tx. No downstream config needed. eRPC handles network routing. | +| Q2 | Auth mechanism | ClusterIP isolation only, no bearer token | Web3Signer ETH1 doesn't support bearer auth natively. ClusterIP + no HTTPRoute is sufficient for local dev. | +| Q3 | Shared PVC | Not needed β€” volumes are separate | Key generation happens at init time via Go code. Skill uses HTTP only. | +| Q4 | K8s security without NetworkPolicy | ClusterIP + no HTTPRoute + no Ingress | Flannel doesn't enforce NetworkPolicy, but ClusterIP services are only reachable in-cluster. No external route = no external access. | +| Q5 | Frontend access to public keys | ConfigMap `web3signer-metadata` per instance | Created by Go code at init time. Frontend reads via existing ClusterRole. One ConfigMap per web3signer, aggregated across namespaces. | +| Q6 | Multi-network signing | One web3signer per instance, chain-agnostic keys | SECP256K1 keys work on any EVM chain. Network routing is the skill script's concern via eRPC path. | +| Q7 | secp256k1 in Go | Check for go-ethereum/crypto or add decred/secp256k1 | Go stdlib only has P256. Need a secp256k1 implementation. | + +--- + +## 8. Testing Strategy + +### Unit Tests (`internal/openclaw/web3signer_test.go`) + +| Test | Validates | +|------|-----------| +| `TestGenerateSigningKey` | Valid secp256k1 key, correct address derivation, deterministic length | +| `TestProvisionKeyFiles` | `.hex` file has 64 chars, `.toml` has correct structure, file permissions | +| `TestGenerateWeb3SignerValues` | Valid YAML, ETH1 enabled, PostgreSQL disabled, correct persistence config | +| `TestCreateMetadataConfigMap` | Correct JSON structure, address format, label selectors | +| `TestGenerateHelmfileWithWeb3Signer` | Two releases (openclaw + web3signer), same namespace, ethereum repo present | + +### Integration Tests (`internal/openclaw/integration_test.go`) + +| Test | Tag | Validates | +|------|-----|-----------| +| `TestIntegration_Web3SignerDeploy` | `integration` | Pod starts, /upcheck returns OK, eth_accounts returns address | +| `TestIntegration_SignMessage` | `integration` | eth_sign returns valid signature format | +| `TestIntegration_SignTransaction` | `integration` | eth_signTransaction returns RLP hex | +| `TestIntegration_MetadataConfigMap` | `integration` | ConfigMap exists, contains correct address | + +### Skill Smoke Tests (in-pod Python) + +| Test | Validates | +|------|-----------| +| `test_health` | Web3Signer reachable at $WEB3SIGNER_URL | +| `test_accounts` | At least one address returned | +| `test_sign` | Signature for known data matches expected format | +| `test_erpc_reachable` | eRPC responds to eth_blockNumber | + +--- + +## 9. Dependencies + +| Dependency | Version | Source | Notes | +|------------|---------|--------|-------| +| `ethereum/web3signer` Helm chart | 1.0.6 | ethpandaops helm-charts | Already have `ethereum` repo in infra helmfile | +| `consensys/web3signer` Docker image | 24.12.1 | Docker Hub | Pin specific version | +| secp256k1 Go library | TBD | go-ethereum or decred | For key generation in Go | +| PostgreSQL (chart sub-dep) | β€” | Disabled | Not needed for ETH1 file-based keys | + +--- + +## 10. Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| No NetworkPolicy enforcement (Flannel) | Low β€” local dev only | ClusterIP + no HTTPRoute. No internet-facing path to web3signer. | +| Key material stored as raw hex on PVC | Medium β€” unencrypted at rest | Acceptable for dev. Future: V3 encrypted keystores. | +| secp256k1 not in Go stdlib | Low β€” implementation detail | Use go-ethereum/crypto or decred/secp256k1. Check go.mod first. | +| `eth_signTransaction` return format varies | Medium β€” may not return raw RLP | Test with actual Web3Signer. Fallback: minimal RLP encoder in Python (~50 lines). | +| PVC not available before pod start | Low β€” race condition | Key files written at init time, before `helmfile sync`. PVC is populated before web3signer starts. | + +--- + +## 11. Future Enhancements (Out of Scope) + +- `obol web3signer add-key` β€” CLI command to add more keys post-init +- ETH2/BLS signing β€” validator attestations, blocks, voluntary exits +- Encrypted keystores (V3) β€” password-protected keys +- Hardware signer backends β€” HashiCorp Vault, AWS KMS +- Transaction builder in frontend β€” UI for composing transactions +- Gas estimation intelligence β€” smart gas pricing +- Key export/backup β€” encrypted key export for disaster recovery +- Multi-instance key isolation β€” per-agent key namespaces