From b0b681baab5e49458eda69091c3eae1a40364166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 20 Feb 2026 13:06:51 +0000 Subject: [PATCH 01/14] Fix up dev mode --- obolup.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/obolup.sh b/obolup.sh index d965967..2412177 100755 --- a/obolup.sh +++ b/obolup.sh @@ -15,11 +15,11 @@ NC='\033[0m' # No Color # Development mode detection if [[ "${OBOL_DEVELOPMENT:-false}" == "true" ]]; then # Get script directory for development mode - # Use parameter expansion with default to handle curl | bash case - if [[ -n "${BASH_SOURCE[0]:-}" ]]; then + # Use -f to check for a real file, not /dev/fd/* from process substitution + if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" else - # Fallback to current directory if BASH_SOURCE not available + # Fallback to current directory for curl | bash or bash <(...) cases SCRIPT_DIR="$(pwd)" fi WORKSPACE_DIR="$SCRIPT_DIR/.workspace" From 4bf81a5bc65afe96bd384a0daf9d6a2a6fdab372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 20 Feb 2026 13:27:00 +0000 Subject: [PATCH 02/14] Update monitoring tls --- internal/embed/infrastructure/values/monitoring.yaml.gotmpl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/embed/infrastructure/values/monitoring.yaml.gotmpl b/internal/embed/infrastructure/values/monitoring.yaml.gotmpl index a7a6095..b96e1c1 100644 --- a/internal/embed/infrastructure/values/monitoring.yaml.gotmpl +++ b/internal/embed/infrastructure/values/monitoring.yaml.gotmpl @@ -23,6 +23,8 @@ prometheus: prometheusOperator: admissionWebhooks: enabled: false # Disable webhook pre-install hooks (avoids timeout on fresh k3s) + tls: + enabled: false # Disable TLS secret volume mount (no admission webhook = no cert needed) grafana: enabled: false # Enable when we want UI access From c4153971461b124bff96a87748cc19eaf63d4252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 20 Feb 2026 13:59:26 +0000 Subject: [PATCH 03/14] Work on wallet --- go.mod | 3 +- go.sum | 2 + .../embed/skills/ethereum-networks/SKILL.md | 2 +- .../embed/skills/ethereum-wallet/SKILL.md | 121 ++- .../references/web3signer-api.md | 97 +++ .../skills/ethereum-wallet/scripts/signer.py | 292 +++++++ internal/openclaw/openclaw.go | 53 +- internal/openclaw/web3signer.go | 351 ++++++++ internal/openclaw/web3signer_test.go | 284 +++++++ plans/ethereum-wallet-web3signer.md | 763 ++++++++++++++++++ 10 files changed, 1940 insertions(+), 28 deletions(-) create mode 100644 internal/embed/skills/ethereum-wallet/references/web3signer-api.md create mode 100644 internal/embed/skills/ethereum-wallet/scripts/signer.py create mode 100644 internal/openclaw/web3signer.go create mode 100644 internal/openclaw/web3signer_test.go create mode 100644 plans/ethereum-wallet-web3signer.md 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/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/openclaw.go b/internal/openclaw/openclaw.go index 984d807..91cb44f 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,21 +210,46 @@ 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) + } + fmt.Printf(" Signing address: %s\n", signingKey.Address) + + // 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(" Signing: %s\n", signingKey.Address) 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) } @@ -301,6 +330,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") @@ -1714,7 +1747,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 +1756,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 +1767,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/web3signer.go b/internal/openclaw/web3signer.go new file mode 100644 index 0000000..bf8442e --- /dev/null +++ b/internal/openclaw/web3signer.go @@ -0,0 +1,351 @@ +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 = "24.12.1" + 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 raw hex private key file + keyFile := filepath.Join(keysDir, key.KeyID+".hex") + if err := os.WriteFile(keyFile, []byte(key.PrivateKeyHex), 0600); err != nil { + return fmt.Errorf("failed to write key file: %w", err) + } + + // Write Web3Signer TOML key config pointing to the key file. + // The filename path must match the container mount, not the host path. + tomlContent := fmt.Sprintf(`[metadata] +description = "%s" + +[signing] +type = "file-raw" +filename = "/data/%s.hex" +`, label, key.KeyID) + + configFile := filepath.Join(keysDir, key.KeyID+".toml") + if err := os.WriteFile(configFile, []byte(tomlContent), 0644); 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. This maps into the pod via the k3d volume +// mount and local-path-provisioner. +func Web3SignerKeysPath(cfg *config.Config, id string) string { + namespace := fmt.Sprintf("%s-%s", appName, id) + return filepath.Join(cfg.DataDir, namespace, "web3signer-data") +} + +// 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(" βœ“ Signing address: %s\n", signingKey.Address) + + // 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" + +# ETH1 signing mode β€” keys loaded from /data/ (chart persistence mount) +extraArgs: + - "--eth1-enabled" + - "--key-store-path=/data" + - "--http-host-allowlist=*" + +# 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 + +# PostgreSQL is not needed for ETH1 file-based keys. +postgresql: + 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..0e5292a --- /dev/null +++ b/internal/openclaw/web3signer_test.go @@ -0,0 +1,284 @@ +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 .hex file exists and has correct content + hexFile := filepath.Join(dir, "testkey1.hex") + hexContent, err := os.ReadFile(hexFile) + if err != nil { + t.Fatalf("failed to read hex file: %v", err) + } + if string(hexContent) != key.PrivateKeyHex { + t.Errorf("hex file content = %q, want %q", string(hexContent), key.PrivateKeyHex) + } + + // Check .hex file permissions (0600) + info, err := os.Stat(hexFile) + if err != nil { + t.Fatalf("failed to stat hex file: %v", err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("hex file permissions = %o, want 0600", perm) + } + + // Check .toml file exists and has correct structure + tomlFile := filepath.Join(dir, "testkey1.toml") + tomlContent, err := os.ReadFile(tomlFile) + if err != nil { + t.Fatalf("failed to read toml file: %v", err) + } + toml := string(tomlContent) + if !strings.Contains(toml, `type = "file-raw"`) { + t.Error("toml should contain type = file-raw") + } + if !strings.Contains(toml, `filename = "/data/testkey1.hex"`) { + t.Error("toml should contain correct filename path") + } + if !strings.Contains(toml, `description = "test-agent"`) { + t.Error("toml should contain the label as description") + } +} + +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.hex")); os.IsNotExist(err) { + t.Error("key file 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 enable ETH1 mode + if !strings.Contains(values, "--eth1-enabled") { + t.Error("values should enable ETH1 mode") + } + + // Should set key store path + if !strings.Contains(values, "--key-store-path=/data") { + t.Error("values should set key-store-path to /data") + } + + // Should disable PostgreSQL + if !strings.Contains(values, "postgresql:") || !strings.Contains(values, "enabled: false") { + t.Error("values should disable 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/web3signer-data" + 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/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 From fc780e2b703c4a591b8c14a74bdd124106e7740f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 20 Feb 2026 14:48:57 +0000 Subject: [PATCH 04/14] Update --- internal/openclaw/web3signer.go | 11 ++++++----- internal/openclaw/web3signer_test.go | 6 +++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/internal/openclaw/web3signer.go b/internal/openclaw/web3signer.go index bf8442e..9b5e6dd 100644 --- a/internal/openclaw/web3signer.go +++ b/internal/openclaw/web3signer.go @@ -18,7 +18,7 @@ import ( const ( web3signerChartVersion = "1.0.6" - web3signerImageTag = "24.12.1" + web3signerImageTag = "25.12.0" web3signerReleaseName = "web3signer" web3signerPort = 9000 ) @@ -40,8 +40,8 @@ func GenerateSigningKey() (*Web3SignerKey, error) { 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 + privBytes := privKey.Serialize() // 32 bytes + pubBytes := privKey.PubKey().SerializeUncompressed() // 65 bytes: 04 || x || y // Ethereum address: keccak256(pubkey_without_prefix)[12:] hash := sha3.NewLegacyKeccak256() @@ -328,8 +328,9 @@ persistence: accessModes: - ReadWriteOnce -# PostgreSQL is not needed for ETH1 file-based keys. -postgresql: +# 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. diff --git a/internal/openclaw/web3signer_test.go b/internal/openclaw/web3signer_test.go index 0e5292a..e952441 100644 --- a/internal/openclaw/web3signer_test.go +++ b/internal/openclaw/web3signer_test.go @@ -156,9 +156,9 @@ func TestGenerateWeb3SignerValues(t *testing.T) { t.Error("values should set key-store-path to /data") } - // Should disable PostgreSQL - if !strings.Contains(values, "postgresql:") || !strings.Contains(values, "enabled: false") { - t.Error("values should disable PostgreSQL") + // 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 From b8f64066d7efab69d66f3aea05918582b3b64e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Sat, 21 Feb 2026 20:37:53 +0000 Subject: [PATCH 05/14] WIP --- internal/embed/embed_skills_test.go | 21 ++++--- internal/openclaw/integration_test.go | 6 +- internal/openclaw/openclaw.go | 44 ++++++++++++-- internal/openclaw/skills_injection_test.go | 4 +- internal/openclaw/web3signer.go | 63 +++++++++++--------- internal/openclaw/web3signer_test.go | 57 +++++++----------- internal/stack/backend_k3d.go | 67 ++++++++++++++++++++++ internal/stack/stack.go | 6 +- obolup.sh | 1 + 9 files changed, 186 insertions(+), 83 deletions(-) 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/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 91cb44f..8bd294a 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -231,7 +231,6 @@ func Onboard(cfg *config.Config, opts OnboardOptions) error { os.RemoveAll(deploymentDir) return fmt.Errorf("failed to provision signing key: %w", err) } - fmt.Printf(" Signing address: %s\n", signingKey.Address) // Write Web3Signer Helm values web3signerValues := generateWeb3SignerValues(id) @@ -245,7 +244,6 @@ func Onboard(cfg *config.Config, opts OnboardOptions) error { fmt.Printf(" Namespace: %s\n", namespace) fmt.Printf(" Hostname: %s\n", hostname) fmt.Printf(" Location: %s\n", deploymentDir) - fmt.Printf(" Signing: %s\n", signingKey.Address) fmt.Printf("\nFiles created:\n") fmt.Printf(" - values-obol.yaml Obol Stack overlay (httpRoute, providers, eRPC)\n") fmt.Printf(" - values-web3signer.yaml Web3Signer configuration (ETH1 signing)\n") @@ -254,6 +252,13 @@ func Onboard(cfg *config.Config, opts OnboardOptions) error { 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) @@ -285,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) @@ -995,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") @@ -1003,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") } @@ -1023,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 } 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 index 9b5e6dd..542570e 100644 --- a/internal/openclaw/web3signer.go +++ b/internal/openclaw/web3signer.go @@ -69,24 +69,16 @@ func ProvisionKeyFiles(keysDir string, key *Web3SignerKey, label string) error { return fmt.Errorf("failed to create keys directory: %w", err) } - // Write raw hex private key file - keyFile := filepath.Join(keysDir, key.KeyID+".hex") - if err := os.WriteFile(keyFile, []byte(key.PrivateKeyHex), 0600); err != nil { - return fmt.Errorf("failed to write key file: %w", err) - } - - // Write Web3Signer TOML key config pointing to the key file. - // The filename path must match the container mount, not the host path. - tomlContent := fmt.Sprintf(`[metadata] -description = "%s" - -[signing] -type = "file-raw" -filename = "/data/%s.hex" -`, label, key.KeyID) - - configFile := filepath.Join(keysDir, key.KeyID+".toml") - if err := os.WriteFile(configFile, []byte(tomlContent), 0644); err != nil { + // 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) } @@ -94,11 +86,13 @@ filename = "/data/%s.hex" } // Web3SignerKeysPath returns the host-side directory where Web3Signer -// key files are provisioned. This maps into the pod via the k3d volume -// mount and local-path-provisioner. +// 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, "web3signer-data") + return filepath.Join(cfg.DataDir, namespace, "storage-web3signer-0", "keys") } // MetadataAddress represents a single signing address in the ConfigMap. @@ -291,7 +285,8 @@ func ensureWeb3Signer(cfg *config.Config, id, deploymentDir string) { fmt.Printf(" Warning: could not provision signing key: %v\n", err) return } - fmt.Printf(" βœ“ Signing address: %s\n", signingKey.Address) + 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) @@ -314,11 +309,25 @@ image: repository: consensys/web3signer tag: "%s" -# ETH1 signing mode β€” keys loaded from /data/ (chart persistence mount) -extraArgs: - - "--eth1-enabled" - - "--key-store-path=/data" - - "--http-host-allowlist=*" +# 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. diff --git a/internal/openclaw/web3signer_test.go b/internal/openclaw/web3signer_test.go index e952441..1206b7e 100644 --- a/internal/openclaw/web3signer_test.go +++ b/internal/openclaw/web3signer_test.go @@ -83,40 +83,29 @@ func TestProvisionKeyFiles(t *testing.T) { t.Fatalf("ProvisionKeyFiles() error: %v", err) } - // Check .hex file exists and has correct content - hexFile := filepath.Join(dir, "testkey1.hex") - hexContent, err := os.ReadFile(hexFile) + // 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 read hex file: %v", err) - } - if string(hexContent) != key.PrivateKeyHex { - t.Errorf("hex file content = %q, want %q", string(hexContent), key.PrivateKeyHex) - } - - // Check .hex file permissions (0600) - info, err := os.Stat(hexFile) - if err != nil { - t.Fatalf("failed to stat hex file: %v", err) + t.Fatalf("failed to stat yaml key config: %v", err) } if perm := info.Mode().Perm(); perm != 0600 { - t.Errorf("hex file permissions = %o, want 0600", perm) + t.Errorf("yaml file permissions = %o, want 0600", perm) } - // Check .toml file exists and has correct structure - tomlFile := filepath.Join(dir, "testkey1.toml") - tomlContent, err := os.ReadFile(tomlFile) + yamlContent, err := os.ReadFile(yamlFile) if err != nil { - t.Fatalf("failed to read toml file: %v", err) + t.Fatalf("failed to read yaml key config: %v", err) } - toml := string(tomlContent) - if !strings.Contains(toml, `type = "file-raw"`) { - t.Error("toml should contain type = file-raw") + yaml := string(yamlContent) + if !strings.Contains(yaml, `type: "file-raw"`) { + t.Error("yaml should contain type: file-raw") } - if !strings.Contains(toml, `filename = "/data/testkey1.hex"`) { - t.Error("toml should contain correct filename path") + if !strings.Contains(yaml, `privateKey: "0x`+key.PrivateKeyHex+`"`) { + t.Error("yaml should contain inline private key with 0x prefix") } - if !strings.Contains(toml, `description = "test-agent"`) { - t.Error("toml should contain the label as description") + if !strings.Contains(yaml, `keyType: "SECP256K1"`) { + t.Error("yaml should specify SECP256K1 key type") } } @@ -133,8 +122,8 @@ func TestProvisionKeyFiles_CreatesDirectory(t *testing.T) { t.Fatalf("ProvisionKeyFiles() should create nested dirs: %v", err) } - if _, err := os.Stat(filepath.Join(dir, "k1.hex")); os.IsNotExist(err) { - t.Error("key file not created in nested directory") + if _, err := os.Stat(filepath.Join(dir, "k1.yaml")); os.IsNotExist(err) { + t.Error("key config not created in nested directory") } } @@ -146,14 +135,12 @@ func TestGenerateWeb3SignerValues(t *testing.T) { t.Error("values should reference the instance ID") } - // Should enable ETH1 mode - if !strings.Contains(values, "--eth1-enabled") { - t.Error("values should enable ETH1 mode") + // 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") } - - // Should set key store path - if !strings.Contains(values, "--key-store-path=/data") { - t.Error("values should set key-store-path to /data") + if !strings.Contains(values, "eth1") { + t.Error("values should use eth1 subcommand") } // Should disable slashing protection DB (PostgreSQL) @@ -259,7 +246,7 @@ func TestWeb3SignerKeysPath(t *testing.T) { } path := Web3SignerKeysPath(cfg, "my-agent") - expected := "/home/user/.local/share/obol/openclaw-my-agent/web3signer-data" + expected := "/home/user/.local/share/obol/openclaw-my-agent/storage-web3signer-0/keys" if path != expected { t.Errorf("Web3SignerKeysPath() = %q, want %q", path, expected) } 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 2412177..ebab86b 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 From fa97e5eb93c38070598edc900204aa74a7b987dd Mon Sep 17 00:00:00 2001 From: Aga Skrobot Date: Fri, 20 Feb 2026 16:58:55 +0100 Subject: [PATCH 06/14] chore: bump obol-frontend to v0.1.7 (#203) Co-authored-by: Claude Sonnet 4.6 --- internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl b/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl index c8e9429..3a5152e 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.6" + tag: "v0.1.7" service: type: ClusterIP From fba7f4e40e63ab6c8c6e4c799db76852221e162a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Sat, 21 Feb 2026 20:46:26 +0000 Subject: [PATCH 07/14] Add ethskills --- internal/embed/skills/addresses/SKILL.md | 544 ++++++++++++++++++ .../embed/skills/building-blocks/SKILL.md | 244 ++++++++ internal/embed/skills/concepts/SKILL.md | 217 +++++++ .../embed/skills/frontend-playbook/SKILL.md | 363 ++++++++++++ internal/embed/skills/frontend-ux/SKILL.md | 346 +++++++++++ internal/embed/skills/gas/SKILL.md | 121 ++++ internal/embed/skills/indexing/SKILL.md | 318 ++++++++++ internal/embed/skills/l2s/SKILL.md | 190 ++++++ internal/embed/skills/orchestration/SKILL.md | 300 ++++++++++ internal/embed/skills/qa/SKILL.md | 222 +++++++ internal/embed/skills/security/SKILL.md | 470 +++++++++++++++ internal/embed/skills/ship/SKILL.md | 297 ++++++++++ internal/embed/skills/standards/SKILL.md | 315 ++++++++++ internal/embed/skills/testing/SKILL.md | 379 ++++++++++++ internal/embed/skills/tools/SKILL.md | 164 ++++++ internal/embed/skills/wallets/SKILL.md | 168 ++++++ internal/embed/skills/why/SKILL.md | 87 +++ 17 files changed, 4745 insertions(+) create mode 100644 internal/embed/skills/addresses/SKILL.md create mode 100644 internal/embed/skills/building-blocks/SKILL.md create mode 100644 internal/embed/skills/concepts/SKILL.md create mode 100644 internal/embed/skills/frontend-playbook/SKILL.md create mode 100644 internal/embed/skills/frontend-ux/SKILL.md create mode 100644 internal/embed/skills/gas/SKILL.md create mode 100644 internal/embed/skills/indexing/SKILL.md create mode 100644 internal/embed/skills/l2s/SKILL.md create mode 100644 internal/embed/skills/orchestration/SKILL.md create mode 100644 internal/embed/skills/qa/SKILL.md create mode 100644 internal/embed/skills/security/SKILL.md create mode 100644 internal/embed/skills/ship/SKILL.md create mode 100644 internal/embed/skills/standards/SKILL.md create mode 100644 internal/embed/skills/testing/SKILL.md create mode 100644 internal/embed/skills/tools/SKILL.md create mode 100644 internal/embed/skills/wallets/SKILL.md create mode 100644 internal/embed/skills/why/SKILL.md diff --git a/internal/embed/skills/addresses/SKILL.md b/internal/embed/skills/addresses/SKILL.md new file mode 100644 index 0000000..9df896e --- /dev/null +++ b/internal/embed/skills/addresses/SKILL.md @@ -0,0 +1,544 @@ +--- +name: addresses +description: Verified contract addresses for major Ethereum protocols across mainnet and L2s. Use this instead of guessing or hallucinating addresses. Includes Uniswap, Aave, Compound, Aerodrome, GMX, Pendle, Velodrome, Camelot, SyncSwap, Lido, Rocket Pool, 1inch, Permit2, MakerDAO/sDAI, EigenLayer, Across, Chainlink CCIP, Yearn V3, USDC, USDT, DAI, ENS, Safe, Chainlink, and more. Always verify addresses against a block explorer before sending transactions. +--- + +# Contract Addresses + +> **CRITICAL:** Never hallucinate a contract address. Wrong addresses mean lost funds. If an address isn't listed here, look it up on the block explorer or the protocol's official docs before using it. + +**Last Verified:** February 16, 2026 (all addresses verified onchain via `eth_getCode` + `eth_call`) + +--- + +## Stablecoins + +### USDC (Circle) β€” Native +| Network | Address | Status | +|---------|---------|--------| +| Mainnet | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | βœ… Verified | +| Arbitrum | `0xaf88d065e77c8cC2239327C5EDb3A432268e5831` | βœ… Verified | +| Optimism | `0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85` | βœ… Verified | +| Base | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | βœ… Verified | +| Polygon | `0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359` | βœ… Verified | +| zkSync Era | `0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4` | βœ… Verified | + +### USDT (Tether) +| Network | Address | Status | +|---------|---------|--------| +| Mainnet | `0xdAC17F958D2ee523a2206206994597C13D831ec7` | βœ… Verified | +| Arbitrum | `0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9` | βœ… Verified | +| Optimism | `0x94b008aA00579c1307B0EF2c499aD98a8ce58e58` | βœ… Verified | +| Base | `0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2` | βœ… Verified | + +### DAI (MakerDAO) +| Network | Address | Status | +|---------|---------|--------| +| Mainnet | `0x6B175474E89094C44Da98b954EedeAC495271d0F` | βœ… Verified | +| Arbitrum | `0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1` | βœ… Verified | +| Optimism | `0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1` | βœ… Verified | +| Base | `0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb` | βœ… Verified | + +--- + +## Wrapped ETH (WETH) + +| Network | Address | Status | +|---------|---------|--------| +| Mainnet | `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` | βœ… Verified | +| Arbitrum | `0x82aF49447D8a07e3bd95BD0d56f35241523fBab1` | βœ… Verified | +| Optimism | `0x4200000000000000000000000000000000000006` | βœ… Verified | +| Base | `0x4200000000000000000000000000000000000006` | βœ… Verified | + +--- + +## Liquid Staking + +### Lido β€” wstETH (Wrapped stETH) +| Network | Address | Status | +|---------|---------|--------| +| Mainnet | `0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0` | βœ… Verified | +| Arbitrum | `0x5979D7b546E38E414F7E9822514be443A4800529` | βœ… Verified | +| Optimism | `0x1F32b1c2345538c0c6f582fCB022739c4A194Ebb` | βœ… Verified | +| Base | `0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452` | βœ… Verified | + +### Lido β€” Staking & Withdrawal +| Contract | Address | Status | +|----------|---------|--------| +| stETH / Lido (deposit ETH here) | `0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84` | βœ… Verified | +| Withdrawal Queue (unstETH NFT) | `0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1` | βœ… Verified | + +### Rocket Pool +| Contract | Address | Status | +|----------|---------|--------| +| rETH Token | `0xae78736Cd615f374D3085123A210448E74Fc6393` | βœ… Verified | +| Deposit Pool v1.1 | `0x2cac916b2A963Bf162f076C0a8a4a8200BCFBfb4` | βœ… Verified | + +--- + +## DeFi Protocols + +### Uniswap + +#### V2 (Mainnet) +| Contract | Address | Status | +|----------|---------|--------| +| Router | `0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D` | βœ… Verified | +| Factory | `0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f` | βœ… Verified | + +#### V3 (Mainnet) +| Contract | Address | Status | +|----------|---------|--------| +| SwapRouter | `0xE592427A0AEce92De3Edee1F18E0157C05861564` | βœ… Verified | +| SwapRouter02 | `0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45` | βœ… Verified | +| Factory | `0x1F98431c8aD98523631AE4a59f267346ea31F984` | βœ… Verified | +| Quoter V2 | `0x61fFE014bA17989E743c5F6cB21bF9697530B21e` | βœ… Verified | +| Position Manager | `0xC36442b4a4522E871399CD717aBDD847Ab11FE88` | βœ… Verified | + +#### V3 Multi-Chain +| Contract | Arbitrum | Optimism | Base | +|----------|----------|----------|------| +| SwapRouter02 | `0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45` βœ… | `0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45` βœ… | `0x2626664c2603336E57B271c5C0b26F421741e481` βœ… | +| Factory | `0x1F98431c8aD98523631AE4a59f267346ea31F984` βœ… | `0x1F98431c8aD98523631AE4a59f267346ea31F984` βœ… | `0x33128a8fC17869897dcE68Ed026d694621f6FDfD` βœ… | + +#### V4 (Live Since January 31, 2025) + +⚠️ **V4 addresses are DIFFERENT per chain** β€” unlike V3, they are NOT deterministic CREATE2 deploys. Do not assume the same address works cross-chain. + +| Contract | Mainnet | Status | +|----------|---------|--------| +| PoolManager | `0x000000000004444c5dc75cB358380D2e3dE08A90` | βœ… Verified | +| PositionManager | `0xbd216513d74c8cf14cf4747e6aaa6420ff64ee9e` | βœ… Verified | +| Quoter | `0x52f0e24d1c21c8a0cb1e5a5dd6198556bd9e1203` | βœ… Verified | +| StateView | `0x7ffe42c4a5deea5b0fec41c94c136cf115597227` | βœ… Verified | + +#### V4 Multi-Chain +| Contract | Arbitrum | Base | Optimism | +|----------|----------|------|----------| +| PoolManager | `0x360e68faccca8ca495c1b759fd9eee466db9fb32` βœ… | `0x498581ff718922c3f8e6a244956af099b2652b2b` βœ… | `0x9a13f98cb987694c9f086b1f5eb990eea8264ec3` βœ… | +| PositionManager | `0xd88f38f930b7952f2db2432cb002e7abbf3dd869` βœ… | `0x7c5f5a4bbd8fd63184577525326123b519429bdc` βœ… | `0x3c3ea4b57a46241e54610e5f022e5c45859a1017` βœ… | + +#### Universal Router (V4 β€” Current) +| Network | Address | Status | +|---------|---------|--------| +| Mainnet | `0x66a9893cc07d91d95644aedd05d03f95e1dba8af` | βœ… Verified | +| Arbitrum | `0xa51afafe0263b40edaef0df8781ea9aa03e381a3` | βœ… Verified | +| Base | `0x6ff5693b99212da76ad316178a184ab56d299b43` | βœ… Verified | +| Optimism | `0x851116d9223fabed8e56c0e6b8ad0c31d98b3507` | βœ… Verified | + +#### Universal Router (V3 β€” Legacy) +| Contract | Address | Status | +|----------|---------|--------| +| Universal Router | `0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD` | βœ… Verified | + +#### Permit2 (Universal Token Approval) + +Used by Uniswap Universal Router and many other protocols. Same address on all chains (CREATE2). + +| Network | Address | Status | +|---------|---------|--------| +| All chains | `0x000000000022D473030F116dDEE9F6B43aC78BA3` | βœ… Verified | + +Verified on: Mainnet, Arbitrum, Base, Optimism (identical bytecode on all). + +#### UNI Token +| Network | Address | Status | +|---------|---------|--------| +| Mainnet | `0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984` | βœ… Verified | + +### 1inch Aggregation Router + +Use aggregators for best swap prices β€” they route across all DEXs. + +#### V6 (Current β€” same address on all chains via CREATE2) +| Network | Address | Status | +|---------|---------|--------| +| Mainnet | `0x111111125421cA6dc452d289314280a0f8842A65` | βœ… Verified | +| Arbitrum | `0x111111125421cA6dc452d289314280a0f8842A65` | βœ… Verified | +| Base | `0x111111125421cA6dc452d289314280a0f8842A65` | βœ… Verified | + +#### V5 (Legacy) +| Network | Address | Status | +|---------|---------|--------| +| Mainnet | `0x1111111254EEB25477B68fb85Ed929f73A960582` | βœ… Verified | + +### MakerDAO / Sky + +| Contract | Address | Status | +|----------|---------|--------| +| DAI Savings Rate (Pot) | `0x197E90f9FAD81970bA7976f33CbD77088E5D7cf7` | βœ… Verified | +| sDAI (Savings Dai ERC-4626) | `0x83F20F44975D03b1b09e64809B757c47f942BEeA` | βœ… Verified | + +sDAI is an ERC-4626 vault β€” deposit DAI, earn DSR automatically. Check current rate via `pot.dsr()`. + +### Aave + +#### V2 (Mainnet - Legacy) +| Contract | Address | Status | +|----------|---------|--------| +| LendingPool | `0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9` | βœ… Verified | + +#### V3 (Mainnet) +| Contract | Address | Status | +|----------|---------|--------| +| Pool | `0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2` | βœ… Verified | +| PoolAddressesProvider | `0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e` | βœ… Verified | + +#### V3 Multi-Chain +| Contract | Arbitrum | Optimism | Base | +|----------|----------|----------|------| +| Pool | `0x794a61358D6845594F94dc1DB02A252b5b4814aD` βœ… | `0x794a61358D6845594F94dc1DB02A252b5b4814aD` βœ… | `0xA238Dd80C259a72e81d7e4664a9801593F98d1c5` βœ… | +| PoolAddressesProvider | `0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb` βœ… | `0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb` βœ… | `0xe20fCBdBfFC4Dd138cE8b2E6FBb6CB49777ad64D` βœ… | + +### Compound + +#### V2 (Mainnet - Legacy) +| Contract | Address | Status | +|----------|---------|--------| +| Comptroller | `0x3d9819210A31b4961b30EF54bE2aeD79B9c9Cd3B` | βœ… Verified | +| cETH | `0x4Ddc2D193948926D02f9B1fE9e1daa0718270ED5` | βœ… Verified | +| cUSDC | `0x39AA39c021dfbaE8faC545936693aC917d5E7563` | βœ… Verified | +| cDAI | `0x5d3a536E4D6DbD6114cc1Ead35777bAB948E3643` | βœ… Verified | + +#### V3 Comet (USDC Markets) +| Network | Address | Status | +|---------|---------|--------| +| Mainnet | `0xc3d688B66703497DAA19211EEdff47f25384cdc3` | βœ… Verified | +| Arbitrum | `0x9c4ec768c28520B50860ea7a15bd7213a9fF58bf` | βœ… Verified | +| Base | `0xb125E6687d4313864e53df431d5425969c15Eb2F` | βœ… Verified | +| Optimism | `0x2e44e174f7D53F0212823acC11C01A11d58c5bCB` | βœ… Verified | + +### Curve Finance (Mainnet) +| Contract | Address | Status | +|----------|---------|--------| +| Address Provider | `0x0000000022D53366457F9d5E68Ec105046FC4383` | βœ… Verified | +| CRV Token | `0xD533a949740bb3306d119CC777fa900bA034cd52` | βœ… Verified | + +### Balancer V2 (Mainnet) +| Contract | Address | Status | +|----------|---------|--------| +| Vault | `0xBA12222222228d8Ba445958a75a0704d566BF2C8` | βœ… Verified | + +--- + +## NFT & Marketplaces + +### OpenSea Seaport +| Version | Address | Status | +|---------|---------|--------| +| Seaport 1.1 | `0x00000000006c3852cbEf3e08E8dF289169EdE581` | βœ… Verified | +| Seaport 1.5 | `0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC` | βœ… Verified | + +Multi-chain via CREATE2 (Ethereum, Polygon, Arbitrum, Optimism, Base). + +### ENS (Mainnet) +| Contract | Address | Status | +|----------|---------|--------| +| Registry | `0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e` | βœ… Verified | +| Public Resolver | `0x231b0Ee14048e9dCcD1d247744d114a4EB5E8E63` | βœ… Verified | +| Registrar Controller | `0x253553366Da8546fC250F225fe3d25d0C782303b` | βœ… Verified | + +--- + +## Infrastructure + +### Safe (Gnosis Safe) +| Contract | Address | Status | +|----------|---------|--------| +| Singleton 1.3.0 | `0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552` | βœ… Verified | +| ProxyFactory | `0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2` | βœ… Verified | +| Singleton 1.4.1 | `0x41675C099F32341bf84BFc5382aF534df5C7461a` | βœ… Verified | +| MultiSend | `0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526` | βœ… Verified | + +### Account Abstraction (ERC-4337) +| Contract | Address | Status | +|----------|---------|--------| +| EntryPoint v0.7 | `0x0000000071727De22E5E9d8BAf0edAc6f37da032` | βœ… Verified | +| EntryPoint v0.6 | `0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789` | βœ… Verified | + +All EVM chains (CREATE2). + +### Chainlink + +#### Mainnet +| Feed | Address | Status | +|------|---------|--------| +| LINK Token | `0x514910771AF9Ca656af840dff83E8264EcF986CA` | βœ… Verified | +| ETH/USD | `0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419` | βœ… Verified | +| BTC/USD | `0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c` | βœ… Verified | +| USDC/USD | `0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6` | βœ… Verified | + +#### Additional Mainnet Feeds +| Feed | Address | Status | +|------|---------|--------| +| LINK/USD | `0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c` | βœ… Verified | +| stETH/USD | `0xCfE54B5cD566aB89272946F602D76Ea879CAb4a8` | βœ… Verified | +| AAVE/USD | `0x547a514d5e3769680Ce22B2361c10Ea13619e8a9` | βœ… Verified | + +All feeds confirmed returning live prices via `latestAnswer()` (Feb 16, 2026). + +#### ETH/USD Price Feeds (Multi-Chain) +| Network | Address | Status | +|---------|---------|--------| +| Arbitrum | `0x639Fe6ab55C921f74e7fac1ee960C0B6293ba612` | βœ… Verified | +| Base | `0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70` | βœ… Verified | +| Optimism | `0x13e3Ee699D1909E989722E753853AE30b17e08c5` | βœ… Verified | + +#### LINK Token (Multi-Chain) +| Network | Address | Status | +|---------|---------|--------| +| Arbitrum | `0xf97f4df75117a78c1A5a0DBb814Af92458539FB4` | βœ… Verified | +| Base | `0x88Fb150BDc53A65fe94Dea0c9BA0a6dAf8C6e196` | βœ… Verified | + +### EigenLayer (Mainnet) + +Restaking protocol. Both are upgradeable proxies (EIP-1967). + +| Contract | Address | Status | +|----------|---------|--------| +| StrategyManager | `0x858646372CC42E1A627fcE94aa7A7033e7CF075A` | βœ… Verified | +| DelegationManager | `0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A` | βœ… Verified | + +Source: [eigenlayer.xyz](https://docs.eigenlayer.xyz/) + +### Chainlink CCIP Router (v1.2.0) + +Cross-chain messaging. Call `typeAndVersion()` to confirm β€” returns "Router 1.2.0". + +| Network | Address | Status | +|---------|---------|--------| +| Mainnet | `0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D` | βœ… Verified | +| Arbitrum | `0x141fa059441E0ca23ce184B6A78bafD2A517DdE8` | βœ… Verified | +| Base | `0x881e3A65B4d4a04dD529061dd0071cf975F58Bcd` | βœ… Verified | + +Source: [docs.chain.link/ccip](https://docs.chain.link/ccip/directory/mainnet) + +### Across Protocol β€” SpokePool + +Cross-chain bridge. All SpokePool contracts are upgradeable proxies. + +| Network | Address | Status | +|---------|---------|--------| +| Mainnet | `0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5` | βœ… Verified | +| Arbitrum | `0xe35e9842fceaCA96570B734083f4a58e8F7C5f2A` | βœ… Verified | +| Base | `0x09aea4b2242abC8bb4BB78D537A67a245A7bEC64` | βœ… Verified | +| Optimism | `0x6f26Bf09B1C792e3228e5467807a900A503c0281` | βœ… Verified | + +Source: [docs.across.to/reference/contract-addresses](https://docs.across.to/reference/contract-addresses) + +### Yearn V3 (Mainnet) + +Deployed via CREATE2. Addresses below verified on Mainnet β€” verify on other chains before use. + +| Contract | Address | Status | +|----------|---------|--------| +| VaultFactory 3.0.4 | `0x770D0d1Fb036483Ed4AbB6d53c1C88fb277D812F` | βœ… Verified | +| TokenizedStrategy | `0xDFC8cD9F2f2d306b7C0d109F005DF661E14f4ff2` | βœ… Verified | +| 4626 Router | `0x1112dbCF805682e828606f74AB717abf4b4FD8DE` | βœ… Verified | + +Source: [docs.yearn.fi/developers/addresses/v3-contracts](https://docs.yearn.fi/developers/addresses/v3-contracts) + +### Deterministic Deployer (CREATE2) + +| Contract | Address | Status | +|----------|---------|--------| +| Arachnid's Deployer | `0x4e59b44847b379578588920cA78FbF26c0B4956C` | βœ… Verified | + +Same address on every EVM chain. Used by many protocols for deterministic deployments. + +--- + +## L2-Native Protocols + +> **The dominant DEX on each L2 is NOT Uniswap.** Aerodrome dominates Base, Velodrome dominates Optimism, Camelot is a major native DEX on Arbitrum. Don't default to Uniswap β€” check which DEX has the deepest liquidity on each chain. + +### Aerodrome (Base) β€” Dominant DEX + +The largest DEX on Base by TVL (~$500-600M). Uses the ve(3,3) model β€” **LPs earn AERO emissions, veAERO voters earn 100% of trading fees.** This is the opposite of Uniswap where LPs earn fees directly. + +| Contract | Address | Status | +|----------|---------|--------| +| AERO Token | `0x940181a94A35A4569E4529A3CDfB74e38FD98631` | βœ… Verified | +| Router | `0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43` | βœ… Verified | +| Voter | `0x16613524e02ad97eDfeF371bC883F2F5d6C480A5` | βœ… Verified | +| VotingEscrow | `0xeBf418Fe2512e7E6bd9b87a8F0f294aCDC67e6B4` | βœ… Verified | +| PoolFactory | `0x420DD381b31aEf6683db6B902084cB0FFECe40Da` | βœ… Verified | +| GaugeFactory | `0x35f35cA5B132CaDf2916BaB57639128eAC5bbcb5` | βœ… Verified | +| Minter | `0xeB018363F0a9Af8f91F06FEe6613a751b2A33FE5` | βœ… Verified | +| RewardsDistributor | `0x227f65131A261548b057215bB1D5Ab2997964C7d` | βœ… Verified | +| FactoryRegistry | `0x5C3F18F06CC09CA1910767A34a20F771039E37C0` | βœ… Verified | + +Source: [aerodrome-finance/contracts](https://github.com/aerodrome-finance/contracts) + +### Velodrome V2 (Optimism) β€” Dominant DEX + +Same ve(3,3) model as Aerodrome β€” same team (Dromos Labs). Velodrome was built first for Optimism, Aerodrome is the Base fork. Both merged into "Aero" in November 2025. + +| Contract | Address | Status | +|----------|---------|--------| +| VELO Token (V2) | `0x9560e827aF36c94D2Ac33a39bCE1Fe78631088Db` | βœ… Verified | +| Router | `0xa062aE8A9c5e11aaA026fc2670B0D65cCc8B2858` | βœ… Verified | +| Voter | `0x41C914ee0c7E1A5edCD0295623e6dC557B5aBf3C` | βœ… Verified | +| VotingEscrow | `0xFAf8FD17D9840595845582fCB047DF13f006787d` | βœ… Verified | +| PoolFactory | `0xF1046053aa5682b4F9a81b5481394DA16BE5FF5a` | βœ… Verified | +| Minter | `0x6dc9E1C04eE59ed3531d73a72256C0da46D10982` | βœ… Verified | +| GaugeFactory | `0x8391fE399640E7228A059f8Fa104b8a7B4835071` | βœ… Verified | +| FactoryRegistry | `0xF4c67CdEAaB8360370F41514d06e32CcD8aA1d7B` | βœ… Verified | + +⚠️ **V1 VELO token** (`0x3c8B650257cFb5f272f799F5e2b4e65093a11a05`) is deprecated. Use V2 above. + +Source: [velodrome-finance/contracts](https://github.com/velodrome-finance/contracts) + +### GMX V2 (Arbitrum) β€” Perpetual DEX + +Leading onchain perpetual exchange. V2 uses isolated GM pools per market (Fully Backed and Synthetic). Competes with Hyperliquid. + +| Contract | Address | Status | +|----------|---------|--------| +| GMX Token | `0xfc5A1A6EB076a2C7aD06eD22C90d7E710E35ad0a` | βœ… Verified | +| Exchange Router (latest) | `0x1C3fa76e6E1088bCE750f23a5BFcffa1efEF6A41` | βœ… Verified | +| Exchange Router (previous) | `0x7C68C7866A64FA2160F78EeAe12217FFbf871fa8` | βœ… Verified | +| DataStore | `0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8` | βœ… Verified | +| Reader | `0x470fbC46bcC0f16532691Df360A07d8Bf5ee0789` | βœ… Verified | +| Reward Router V2 | `0xA906F338CB21815cBc4Bc87ace9e68c87eF8d8F1` | βœ… Verified | + +**Note:** Both Exchange Router addresses are valid β€” both point to the same DataStore. The latest (`0x1C3f...`) is from the current gmx-synthetics repo deployment. + +Source: [gmx-io/gmx-synthetics](https://github.com/gmx-io/gmx-synthetics) + +### Pendle (Arbitrum) β€” Yield Trading + +Tokenizes future yield into PT (Principal Token) and YT (Yield Token). Core invariant: `SY_value = PT_value + YT_value`. Multi-chain (also on Ethereum, Base, Optimism). + +| Contract | Address | Status | +|----------|---------|--------| +| PENDLE Token | `0x0c880f6761F1af8d9Aa9C466984b80DAb9a8c9e8` | βœ… Verified | +| Router | `0x888888888889758F76e7103c6CbF23ABbF58F946` | βœ… Verified | +| RouterStatic | `0xAdB09F65bd90d19e3148D9ccb693F3161C6DB3E8` | βœ… Verified | +| Market Factory V3 | `0x2FCb47B58350cD377f94d3821e7373Df60bD9Ced` | βœ… Verified | +| Market Factory V4 | `0xd9f5e9589016da862D2aBcE980A5A5B99A94f3E8` | βœ… Verified | +| PT/YT Oracle | `0x5542be50420E88dd7D5B4a3D488FA6ED82F6DAc2` | βœ… Verified | +| Limit Router | `0x000000000000c9B3E2C3Ec88B1B4c0cD853f4321` | βœ… Verified | +| Yield Contract Factory V3 | `0xEb38531db128EcA928aea1B1CE9E5609B15ba146` | βœ… Verified | +| Yield Contract Factory V4 | `0xc7F8F9F1DdE1104664b6fC8F33E49b169C12F41E` | βœ… Verified | + +Source: [pendle-finance/pendle-core-v2-public](https://github.com/pendle-finance/pendle-core-v2-public/blob/main/deployments/42161-core.json) + +### Camelot (Arbitrum) β€” Native DEX + +Arbitrum-native DEX with concentrated liquidity and launchpad. Two AMM versions: V2 (constant product) and V4 (Algebra concentrated liquidity). + +| Contract | Address | Status | +|----------|---------|--------| +| GRAIL Token | `0x3d9907F9a368ad0a51Be60f7Da3b97cf940982D8` | βœ… Verified | +| xGRAIL | `0x3CAaE25Ee616f2C8E13C74dA0813402eae3F496b` | βœ… Verified | +| Router (AMM V2) | `0xc873fEcbd354f5A56E00E710B90EF4201db2448d` | βœ… Verified | +| Factory (AMM V2) | `0x6EcCab422D763aC031210895C81787E87B43A652` | βœ… Verified | +| SwapRouter (AMM V4 / Algebra) | `0x4ee15342d6Deb297c3A2aA7CFFd451f788675F53` | βœ… Verified | +| AlgebraFactory (AMM V4) | `0xBefC4b405041c5833f53412fF997ed2f697a2f37` | βœ… Verified | + +Source: [docs.camelot.exchange](https://docs.camelot.exchange/contracts/arbitrum/one-mainnet) + +### SyncSwap (zkSync Era) β€” Dominant DEX + +The leading native DEX on zkSync Era. Multiple router and factory versions. + +| Contract | Address | Status | +|----------|---------|--------| +| Router V1 | `0x2da10A1e27bF85cEdD8FFb1AbBe97e53391C0295` | βœ… Verified | +| Router V2 | `0x9B5def958d0f3b6955cBEa4D5B7809b2fb26b059` | βœ… Verified | +| Router V3 | `0x1B887a14216Bdeb7F8204Ee6a269Bd9Ff73A084C` | βœ… Verified | +| Classic Pool Factory V1 | `0xf2DAd89f2788a8CD54625C60b55cD3d2D0ACa7Cb` | βœ… Verified | +| Classic Pool Factory V2 | `0x0a34FBDf37C246C0B401da5f00ABd6529d906193` | βœ… Verified | +| Stable Pool Factory V1 | `0x5b9f21d407F35b10CbfDDca17D5D84b129356ea3` | βœ… Verified | +| Vault V1 | `0x621425a1Ef6abE91058E9712575dcc4258F8d091` | βœ… Verified | + +**Note:** SYNC token is not yet deployed. + +Source: [docs.syncswap.xyz](https://docs.syncswap.xyz/syncswap/smart-contracts/smart-contracts) + +### Morpho Blue (Base) + +Permissionless lending protocol. Deployed on Base and Ethereum, but **NOT on Arbitrum** as of February 2026 (despite the vanity CREATE2 address). + +| Contract | Address | Chain | Status | +|----------|---------|-------|--------| +| Morpho | `0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb` | Base | βœ… Verified | +| Morpho | `0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb` | Arbitrum | ❌ Not deployed | + +Source: [docs.morpho.org](https://docs.morpho.org/get-started/resources/addresses/) + +--- + +## AI & Agent Standards + +### ERC-8004 (Same addresses on 20+ chains) +| Contract | Address | Status | +|----------|---------|--------| +| IdentityRegistry | `0x8004A169FB4a3325136EB29fA0ceB6D2e539a432` | βœ… Verified | +| ReputationRegistry | `0x8004BAa17C55a88189AE136b182e5fdA19dE9b63` | βœ… Verified | + +Verified on: Mainnet, Arbitrum, Base, Optimism (CREATE2 β€” same address on all chains). + +--- + +## Major Tokens (Mainnet) + +| Token | Address | Status | +|-------|---------|--------| +| UNI | `0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984` | βœ… Verified | +| AAVE | `0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9` | βœ… Verified | +| COMP | `0xc00e94Cb662C3520282E6f5717214004A7f26888` | βœ… Verified | +| MKR | `0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2` | βœ… Verified | +| LDO | `0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32` | βœ… Verified | +| WBTC | `0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599` | βœ… Verified | +| stETH (Lido) | `0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84` | βœ… Verified | +| rETH (Rocket Pool) | `0xae78736Cd615f374D3085123A210448E74Fc6393` | βœ… Verified | + +--- + +## How to Verify Addresses + +```bash +# Check bytecode exists (use local eRPC if running in Obol Stack) +cast code 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --rpc-url http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet +# Fallback public RPC: https://eth.llamarpc.com +``` + +**Cross-reference:** Protocol docs β†’ CoinGecko β†’ block explorer β†’ GitHub deployments. + +**EIP-55 Checksum:** Mixed case = checksum. Most tools validate automatically. + +## Address Discovery Resources + +- **Uniswap:** https://docs.uniswap.org/contracts/v3/reference/deployments/ +- **Aave:** https://docs.aave.com/developers/deployed-contracts/deployed-contracts +- **Compound V3:** https://docs.compound.finance/ +- **Chainlink:** https://docs.chain.link/data-feeds/price-feeds/addresses +- **Aerodrome:** https://github.com/aerodrome-finance/contracts +- **Velodrome:** https://github.com/velodrome-finance/contracts +- **GMX:** https://github.com/gmx-io/gmx-synthetics +- **Pendle:** https://github.com/pendle-finance/pendle-core-v2-public +- **Camelot:** https://docs.camelot.exchange/contracts/arbitrum/one-mainnet +- **SyncSwap:** https://docs.syncswap.xyz/syncswap/smart-contracts/smart-contracts +- **Morpho:** https://docs.morpho.org/get-started/resources/addresses/ +- **Lido:** https://docs.lido.fi/deployed-contracts/ +- **Rocket Pool:** https://docs.rocketpool.net/overview/contracts-integrations +- **1inch:** https://docs.1inch.io/docs/aggregation-protocol/introduction +- **EigenLayer:** https://docs.eigenlayer.xyz/ +- **Across:** https://docs.across.to/reference/contract-addresses +- **Chainlink CCIP:** https://docs.chain.link/ccip/directory/mainnet +- **Yearn V3:** https://docs.yearn.fi/developers/addresses/v3-contracts +- **CoinGecko:** https://www.coingecko.com (token addresses) +- **Token Lists:** https://tokenlists.org/ +- **DeFi Llama:** https://defillama.com (TVL rankings by chain) + +## Multi-Chain Notes + +- **CREATE2 deployments** (same address cross-chain): Uniswap V3, Safe, Seaport, ERC-4337 EntryPoint, ERC-8004, Permit2, 1inch v6, Yearn V3, Arachnid Deployer +- **Different addresses per chain:** USDC, USDT, DAI, WETH, wstETH, **Uniswap V4**, Across SpokePool, Chainlink CCIP Router β€” always check per-chain +- **Native vs Bridged USDC:** Some chains have both! Use native. + +--- + +βœ… **All addresses verified onchain via `eth_getCode` + `eth_call` β€” February 16, 2026. Bytecode confirmed present, identity confirmed via symbol/name/cross-reference calls. Does NOT guarantee safety β€” always verify on block explorer before sending transactions.** diff --git a/internal/embed/skills/building-blocks/SKILL.md b/internal/embed/skills/building-blocks/SKILL.md new file mode 100644 index 0000000..2085034 --- /dev/null +++ b/internal/embed/skills/building-blocks/SKILL.md @@ -0,0 +1,244 @@ +--- +name: building-blocks +description: DeFi legos and protocol composability on Ethereum and L2s. Major protocols per chain β€” Aerodrome on Base, GMX/Pendle on Arbitrum, Velodrome on Optimism β€” plus mainnet primitives (Uniswap, Aave, Compound, Curve). How they work, how to build on them, and how to combine them. Use when building DeFi integrations, choosing protocols on a specific L2, designing yield strategies, or composing existing protocols into something new. +--- + +# Building Blocks (DeFi Legos) + +## What You Probably Got Wrong + +**DeFi TVL:** Ethereum DeFi TVL is **$50B+** (Feb 2026). If you're quoting numbers from 2023-2024, they're stale. + +**Uniswap V4 is live.** Launched mainnet **January 31, 2025** on 10+ chains. V4 introduced a **hooks system** β€” custom logic attached to pools (dynamic fees, TWAMM, limit orders, custom oracles). This is the biggest composability upgrade since flash loans. PoolManager addresses are different per chain (NOT deterministic like V3). + +**Costs changed everything:** A flash loan arbitrage on mainnet costs ~$0.05-0.50 in gas now (was $5-50). This opens composability patterns that were previously uneconomical. + +**The dominant DEX on each L2 is NOT Uniswap.** Aerodrome and Velodrome merged into **Aero** (November 2025, Dromos Labs) β€” the unified DEX dominates both Base and Optimism. Camelot is a major native DEX on Arbitrum. Don't default to Uniswap on every chain. + +## Key Protocol Addresses (Verified Feb 2026) + +| Protocol | Contract | Mainnet Address | +|----------|----------|-----------------| +| Uniswap V2 Router | Router | `0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D` | +| Uniswap V2 Factory | Factory | `0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f` | +| Uniswap V3 Factory | Factory | `0x1F98431c8aD98523631AE4a59f267346ea31F984` | +| Uniswap V3 SwapRouter02 | Router | `0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45` | +| Uniswap V4 PoolManager | PoolManager | `0x000000000004444c5dc75cB358380D2e3dE08A90` | +| Uniswap Universal Router (V4) | Router | `0x66a9893cc07d91d95644aedd05d03f95e1dba8af` | +| Aave V3 Pool | Pool | `0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2` | + +See `addresses/SKILL.md` for complete multi-chain address list including L2-native protocols (Aerodrome, GMX, Pendle, Velodrome, Camelot, SyncSwap, Morpho). + +## Uniswap V4 Hooks (New) + +Hooks let you add custom logic that runs before/after swaps, liquidity changes, and donations. This is the biggest composability upgrade since flash loans. + +### Hook Interface (Solidity) + +```solidity +import {BaseHook} from "v4-periphery/src/utils/BaseHook.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "v4-core/types/BeforeSwapDelta.sol"; + +contract DynamicFeeHook is BaseHook { + constructor(IPoolManager _manager) BaseHook(_manager) {} + + function getHookPermissions() public pure override returns (Hooks.Permissions memory) { + return Hooks.Permissions({ + beforeInitialize: false, + afterInitialize: false, + beforeAddLiquidity: false, + afterAddLiquidity: false, + beforeRemoveLiquidity: false, + afterRemoveLiquidity: false, + beforeSwap: true, // ← We hook here + afterSwap: false, + beforeDonate: false, + afterDonate: false, + beforeSwapReturnDelta: false, + afterSwapReturnDelta: false, + afterAddLiquidityReturnDelta: false, + afterRemoveLiquidityReturnDelta: false + }); + } + + // Dynamic fee: higher fee during high-volume periods + function beforeSwap( + address, + PoolKey calldata key, + IPoolManager.SwapParams calldata params, + bytes calldata + ) external override returns (bytes4, BeforeSwapDelta, uint24) { + // Return dynamic fee override (e.g., 0.05% normally, 0.30% during volatility) + uint24 fee = _isHighVolatility() ? 3000 : 500; + return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, fee | 0x800000); + } +} +``` + +**Hook use cases with real code patterns:** +- **Dynamic fees** β€” adjust based on volatility, time-of-day, or oracle data +- **TWAMM** β€” split large orders over time to reduce price impact +- **Limit orders** β€” execute when price crosses a threshold +- **MEV protection** β€” auction swap ordering rights to searchers +- **Custom oracles** β€” TWAP updated on every swap + +## Composability Patterns (Updated for 2026 Gas) + +These patterns are now **economically viable** even for small amounts due to sub-dollar gas: + +### Flash Loan Arbitrage +Borrow from Aave β†’ swap on Uniswap for profit β†’ repay Aave. All in one transaction. If unprofitable, reverts (lose only gas: ~$0.05-0.50). + +### Leveraged Yield Farming +Deposit ETH on Aave β†’ borrow stablecoin β†’ swap for more ETH β†’ deposit again β†’ repeat. Gas cost per loop: ~$0.02 on mainnet, negligible on L2. + +### Meta-Aggregation +Route swaps across multiple DEXs for best execution. 1inch and Paraswap check Uniswap, Curve, Sushi simultaneously. + +### ERC-4626 Yield Vaults + +Standard vault interface β€” the "ERC-20 of yield." Every vault exposes the same functions regardless of strategy. + +```solidity +import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract SimpleYieldVault is ERC4626 { + constructor(IERC20 asset_) + ERC4626(asset_) + ERC20("Vault Shares", "vSHARE") + {} + + // totalAssets() drives the share price + // As yield accrues, totalAssets grows β†’ shares worth more + function totalAssets() public view override returns (uint256) { + return IERC20(asset()).balanceOf(address(this)) + _getAccruedYield(); + } +} + +// Usage: deposit/withdraw are standardized +// vault.deposit(1000e6, msg.sender); // deposit 1000 USDC, get shares +// vault.redeem(shares, msg.sender, msg.sender); // burn shares, get USDC back +// vault.convertToAssets(shares); // how much USDC are my shares worth? +``` + +**Why ERC-4626 matters:** Composability. Any protocol can integrate any vault without custom adapters. Yearn V3, Aave's wrapped tokens, Morpho vaults, Pendle yield tokens β€” all ERC-4626. + +### Flash Loan (Aave V3 β€” Complete Pattern) + +```solidity +import {FlashLoanSimpleReceiverBase} from + "@aave/v3-core/contracts/flashloan-v3/base/FlashLoanSimpleReceiverBase.sol"; +import {IPoolAddressesProvider} from + "@aave/v3-core/contracts/interfaces/IPoolAddressesProvider.sol"; + +contract FlashLoanArb is FlashLoanSimpleReceiverBase { + constructor(IPoolAddressesProvider provider) + FlashLoanSimpleReceiverBase(provider) {} + + function executeArb(address token, uint256 amount) external { + // Borrow `amount` of `token` β€” must repay + 0.05% fee in same tx + POOL.flashLoanSimple(address(this), token, amount, "", 0); + } + + function executeOperation( + address asset, + uint256 amount, + uint256 premium, // 0.05% fee + address, + bytes calldata + ) external override returns (bool) { + // --- Your arbitrage logic here --- + // Buy cheap on DEX A, sell expensive on DEX B + // Must end with at least `amount + premium` of `asset` + + uint256 owed = amount + premium; + IERC20(asset).approve(address(POOL), owed); + return true; // If unprofitable, revert here β€” lose only gas (~$0.05-0.50) + } +} +``` + +**Aave V3 Pool (mainnet):** `0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2` +**Flash loan fee:** 0.05% (5 basis points). Free if you repay to an Aave debt position. + +## Building on Base + +**Dominant DEX: Aero** (formerly Aerodrome, ~$500-600M TVL) β€” NOT Uniswap. In November 2025, Dromos Labs merged Aerodrome (Base) and Velodrome (Optimism) into a unified cross-chain DEX called **Aero**. Same contracts, same ve(3,3) model, new brand. + +### How Aero Works (Critical Difference from Uniswap) +- **LPs deposit tokens** into pools β†’ earn **AERO emissions** (not trading fees!) +- **veAERO voters** lock AERO β†’ vote on which pools get emissions β†’ earn **100% of trading fees + bribes** +- This is the opposite of Uniswap where LPs earn fees directly +- **Flywheel:** Pools generating most fees β†’ attract most votes β†’ get most emissions β†’ attract more LPs β†’ deeper liquidity β†’ more fees + +### Aerodrome Swap (Router Interface) +```solidity +// Aerodrome Router: 0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43 (Base) +struct Route { + address from; + address to; + bool stable; // true = stable pair (like Curve), false = volatile (like Uni V2) + address factory; // 0x420DD381b31aEf6683db6B902084cB0FFECe40Da +} + +// Swap via Router +function swapExactTokensForTokens( + uint256 amountIn, + uint256 amountOutMin, + Route[] calldata routes, + address to, + uint256 deadline +) external returns (uint256[] memory amounts); +``` + +### Base-Specific Patterns +- **Coinbase Smart Wallet** β€” ERC-4337 wallet, passkey auth, gasless txs via Coinbase paymaster +- **OnchainKit** β€” `npm create onchain` to bootstrap a Base app with React components +- **Farcaster Frames v2** β€” mini-apps embedded in social posts that trigger onchain actions +- **AgentKit** β€” Coinbase's framework for AI agents to interact onchain + +## Building on Arbitrum (Highest DeFi Liquidity) + +### GMX V2 β€” How GM Pools Work +- **Each market has its own isolated pool** (unlike V1's single GLP pool) +- LPs deposit into GM (liquidity) pools β†’ receive GM tokens +- **Fully Backed markets:** ETH/USD backed by ETH + USDC. Backing tokens match the traded asset. +- **Synthetic markets:** DOGE/USD backed by ETH + USDC. Uses ADL (Auto-Deleveraging) when thresholds are reached. +- LPs earn: trading fees, liquidation fees, borrowing fees, swap fees. But bear risk from trader PnL. + +### Pendle β€” Yield Tokenization +Pendle splits yield-bearing assets into principal and yield components: + +1. **SY (Standardized Yield):** Wraps any yield-bearing asset. E.g., wstETH β†’ SY-wstETH. +2. **PT (Principal Token):** The principal. Redeemable 1:1 at maturity. Trades at a discount (discount = implied yield). +3. **YT (Yield Token):** All yield until maturity. Value decays to 0 at maturity. +4. **Core invariant:** `SY_value = PT_value + YT_value` + +**Use cases:** +- Buy PT at discount = **lock in fixed yield** (like a zero-coupon bond) +- Buy YT = **leverage your yield exposure** (bet yield goes up) +- LP in Pendle pools = earn trading fees + PENDLE incentives + +### Arbitrum-Specific Tech +- **Stylus:** Write smart contracts in Rust/C++/WASM alongside EVM (10-100x gas savings for compute-heavy operations) +- **Orbit:** Launch custom L3 chains (47 live on mainnet) + +See `addresses/SKILL.md` for all verified protocol addresses (GMX, Pendle, Camelot, Aerodrome, Velodrome, SyncSwap, Morpho). + +## Discovery Resources + +- **DeFi Llama:** https://defillama.com β€” TVL rankings, yield rankings, all chains +- **Dune Analytics:** https://dune.com β€” query onchain data +- **ethereum.org/en/dapps/** β€” curated list + +## Guardrails for Composability + +- **Every protocol you compose with is a dependency.** If Aave gets hacked, your vault depending on Aave is affected. +- **Oracle manipulation = exploits.** Verify oracle sources. +- **Impermanent loss** is real for AMM LPs. Quantify it before providing liquidity. +- **The interaction between two safe contracts can create unsafe behavior.** Audit compositions. +- **Start with small amounts.** Test with minimal value before scaling. +- **Flash loan attacks** can manipulate prices within a single transaction. Design for this. diff --git a/internal/embed/skills/concepts/SKILL.md b/internal/embed/skills/concepts/SKILL.md new file mode 100644 index 0000000..27ef61c --- /dev/null +++ b/internal/embed/skills/concepts/SKILL.md @@ -0,0 +1,217 @@ +--- +name: concepts +description: The essential mental models for building onchain β€” focused on what LLMs get wrong and what humans need explained. "Nothing is automatic" and "incentives are everything" are the core messages. Use when your human is new to onchain development, when they're designing a system, or when they ask "how does this actually work?" Also use when YOU are designing a system β€” the state machine + incentive framework catches design mistakes before they become dead code. +--- + +# Ethereum Concepts + +## What You Probably Got Wrong + +**"Smart contracts run automatically."** No. Smart contracts cannot execute themselves. There is no cron job, no scheduler, no background process. Every function needs a caller who pays gas. This single misconception is the root cause of most broken onchain designs. + +**"Just add a timer."** There are no timers. If something needs to happen at a certain time, you need someone to call the function at that time β€” and you need to give them a reason to do it. + +**"The protocol team will handle that."** The whole point of decentralization is that no single team operates the system. If your design requires an operator, it's not decentralized β€” and it has a single point of failure. + +**Terminology:** You say "on-chain." The Ethereum community says **"onchain"** β€” one word, no hyphen. + +--- + +## Nothing Is Automatic β€” Incentive Design + +**This is the most important concept in all of Ethereum. If you internalize nothing else, internalize this.** + +### Smart Contracts Are State Machines + +A smart contract is a state machine. It sits in one state, and it moves to another state when someone **pokes it** β€” calls a function, pays gas, triggers a transition. Between pokes, it does absolutely nothing. It doesn't think, it doesn't check, it doesn't wake up. It just sits there. + +``` +State A ──[someone calls function]──→ State B ──[someone calls function]──→ State C + ↑ ↑ + WHO does this? WHO does this? + WHY would they? WHY would they? +``` + +There is no cron job. There is no scheduler. There is no background process. The blockchain doesn't call your contract β€” people call your contract. And people don't do things for free. + +**For EVERY state transition in your system, you must answer:** + +1. **Who pokes it?** (someone must pay gas) +2. **Why would they?** (what's their incentive?) +3. **Is the incentive sufficient?** (covers gas + profit?) + +If you can't answer these questions, that state transition will never happen. Your contract will sit in State A forever, doing nothing, with nobody poking it. + +### Incentives Are Everything + +Here's the thing that makes Ethereum magical: **you don't need an operator if you get the incentives right.** + +The people who deployed Uniswap didn't deploy the liquidity. They didn't market-make. They didn't run the exchange. They wrote a set of rules β€” a state machine β€” and aligned the incentives so perfectly that strangers around the world voluntarily deposited billions of dollars, and other strangers voluntarily traded against those deposits, and the fees from the trades flowed back to the depositors, and the whole thing just... worked. + +Nobody runs Uniswap. Nobody CAN stop Uniswap. Not even the people who made it. The contracts are immutable, the incentives are self-sustaining, and the system will run for as long as Ethereum exists. This is a **hyperstructure** β€” an unstoppable protocol that runs forever, with no operator, no company, no server, no admin key. + +That's what you're building toward. Not a startup. Not a service. A set of rules so well-designed that they run themselves through incentives alone. + +### Examples of Good Incentive Design + +**Liquidations (Aave, Compound):** +``` +Loan health factor drops below 1 +β†’ ANYONE can call liquidate() +β†’ Caller gets 5-10% bonus collateral as profit +β†’ Bots compete to do it in milliseconds +β†’ Platform stays solvent without any operator, any admin, any team +``` + +**LP fees (Uniswap):** +``` +DEX needs liquidity to function +β†’ LPs deposit tokens into pools +β†’ Every swap pays 0.3% fee to LPs +β†’ More liquidity = less slippage = more traders = more fees = more liquidity +β†’ Self-reinforcing flywheel β€” nobody manages it +``` + +**Yield harvesting (Yearn):** +``` +Rewards accumulate in a pool +β†’ ANYONE can call harvest() +β†’ Caller gets 1% of the harvest as reward +β†’ Protocol compounds automatically via profit-motivated callers +``` + +**Arbitrage (keeps prices correct everywhere):** +``` +ETH is $2000 on Uniswap, $2010 on SushiSwap +β†’ Anyone can buy low, sell high +β†’ Prices equalize across ALL markets without any coordinator +``` + +### Examples of BAD Design (Missing Incentives) + +``` +❌ "The contract will check prices every hour" + β†’ WHO calls it every hour? WHY would they pay gas? + β†’ Fix: make it profitable to call. Or let users trigger it when they interact. + +❌ "Expired listings get automatically removed" + β†’ Nothing is automatic. WHO removes them? WHY? + β†’ Fix: give callers a small reward, or let the next user's action clean up stale state. + +❌ "The protocol rebalances daily" + β†’ WHOSE gas pays for this? What's their profit? + β†’ Fix: let rebalancing happen during user interactions, or reward the caller. + +❌ "An admin will manually trigger the next phase" + β†’ What if the admin disappears? Gets hit by a bus? Loses their key? + β†’ Fix: make phase transitions permissionless with time-based or condition-based triggers. +``` + +**The fix is always the same:** Don't use an admin account. Make the function callable by **anyone**. Give them a reason to call it. Align incentives so the system pokes itself through the self-interest of its participants. + +### The Hyperstructure Test + +When you're designing a system, ask: **"Could this run forever with no team behind it?"** + +- If yes β†’ you've built a hyperstructure. The incentives sustain it. +- If no β†’ you've built a service. It dies when the team stops operating it. + +Both are valid choices. But know which one you're building. The most powerful things on Ethereum are hyperstructures: Uniswap, ENS, the ERC-20 standard itself. They can't be stopped, they can't be censored, they can't go down. Not because they're maintained β€” because they don't need to be. + +--- + +## Randomness Is Hard + +Smart contracts are deterministic. Every node computes the same result. You can't use `Math.random()` β€” there's nothing random about a system where everyone gets the same answer. + +### What Doesn't Work + +```solidity +// ❌ Validators can manipulate block.timestamp (within ~15 seconds) +uint random = uint(keccak256(abi.encodePacked(block.timestamp))); + +// ❌ blockhash(block.number) is ALWAYS zero for the current block +uint random = uint(blockhash(block.number)); + +// ❌ Validators can withhold blocks if they don't like the result +uint random = uint(blockhash(block.number - 1)); +``` + +### What Works + +**Commit-Reveal** (no external dependency): +``` +1. User commits hash(secret + salt) β†’ stored onchain +2. Wait at least 1 block +3. User reveals secret + salt β†’ contract verifies hash +4. Random seed = keccak256(secret + blockhash(commitBlock)) +``` +- User can't predict blockhash when they commit +- Validator can't know the secret to manipulate the result +- Must reveal within 256 blocks (blockhash returns zero after that) + +**Chainlink VRF** (provably random, costs LINK): +``` +1. Contract requests randomness from Chainlink +2. Chainlink generates random number off-chain with a VRF proof +3. Anyone can verify the proof onchain +4. Guaranteed unbiased β€” even Chainlink can't manipulate it +``` + +Use commit-reveal for simple cases. Use Chainlink VRF when you need provable randomness (lotteries, NFT reveals, gaming). + +--- + +## Teaching Your Human + +You already know how AMMs, lending, oracles, and stablecoins work. But your human might not. When they're designing something onchain, they need to understand these concepts β€” not the code, the *mental models*. + +**Don't lecture.** Instead, when a concept is relevant to what they're building, explain it in context. Here's what to emphasize for each: + +### The Approve Pattern +Humans find this weird. Explain it as: *"You're giving the contract permission to move your tokens, like signing a check. You control how much. Never sign a blank check (infinite approval)."* + +### DEXs / AMMs +The key insight isn't the math β€” it's the incentive flywheel: *"Nobody runs the exchange. People deposit tokens because they earn fees from trades. More deposits = better prices = more trades = more fees. It runs itself."* This is "nothing is automatic" in action. + +### Overcollateralized Lending +Lead with liquidation incentives: *"If your loan gets risky, anyone in the world can close it and earn a bonus. That's why the platform never goes bankrupt β€” thousands of bots are watching every loan, every second, competing to clean up risk."* This is the SpeedRun Ethereum Challenge 6 moment where incentive design clicks. + +### Stablecoins +*"How does a token stay worth $1 with nobody controlling it? Arbitrage. If it drops to $0.99, people buy it because they can redeem it for $1 of collateral. If it goes to $1.01, people mint more and sell. Self-interest maintains the peg."* + +### Oracles +*"Smart contracts can't Google things. If your contract needs a price, someone has to put it onchain. Use Chainlink β€” never read prices from a DEX pool, because a flash loan can fake the price for one transaction."* + +### Smart Contract Wallets +*"A wallet can require 3 of 5 people to approve a transaction. $100B+ is secured this way. It's how teams and DAOs manage money without trusting any single person."* + +### Prediction Markets +*"YES and NO tokens that trade between $0 and $1. The price IS the probability. If YES is at $0.35, the market thinks there's a 35% chance. Winner gets $1, loser gets $0."* + +**In every case, tie it back to incentives.** The human should walk away understanding not just what something does, but WHY it works without anyone running it. + +--- + +## Learning Path + +Point your human to SpeedRun Ethereum for hands-on learning. Each challenge builds a working project that teaches a core concept: + +| # | Challenge | What Clicks | +|---|-----------|-------------| +| 0 | Simple NFT | Minting, metadata, ownership β€” "contracts hold state" | +| 1 | Staking | Deadlines, escrow, thresholds β€” "coordination without trust" | +| 2 | Token Vendor | Approve pattern, buy/sell β€” "contracts can be markets" | +| 3 | Dice Game | Why onchain randomness is insecure β€” "determinism vs. randomness" | +| 4 | DEX | x*y=k, slippage, LP incentives β€” "incentives create markets" | + +**Start at https://speedrunethereum.com** + +More challenges covering oracles, lending, stablecoins, and multisigs are in development. Check the site for current availability. + +## Resources + +- **SpeedRun Ethereum:** https://speedrunethereum.com +- **ETH Tech Tree:** https://www.ethtechtree.com +- **Ethereum.org:** https://ethereum.org/en/developers/ +- **EthSkills (for agents):** https://ethskills.com diff --git a/internal/embed/skills/frontend-playbook/SKILL.md b/internal/embed/skills/frontend-playbook/SKILL.md new file mode 100644 index 0000000..00f8950 --- /dev/null +++ b/internal/embed/skills/frontend-playbook/SKILL.md @@ -0,0 +1,363 @@ +--- +name: frontend-playbook +description: The complete build-to-production pipeline for Ethereum dApps. Fork mode setup, IPFS deployment, Vercel config, ENS subdomain setup, and the full production checklist. Built around Scaffold-ETH 2 but applicable to any Ethereum frontend project. Use when deploying any dApp to production. +--- + +# Frontend Playbook + +## What You Probably Got Wrong + +**"I'll use `yarn chain`."** Wrong. `yarn chain` gives you an empty local chain with no protocols, no tokens, no state. `yarn fork --network base` gives you a copy of real Base with Uniswap, Aave, USDC, real whale balances β€” everything. Always fork. + +**"I deployed to IPFS and it works."** Did the CID change? If not, you deployed stale output. Did routes work? Without `trailingSlash: true`, every route except `/` returns 404. Did you check the OG image? Without `NEXT_PUBLIC_PRODUCTION_URL`, it points to `localhost:3000`. + +**"I'll set up the project manually."** Don't. `npx create-eth@latest` handles everything β€” Foundry, Next.js, RainbowKit, scaffold hooks. Never run `forge init` or create Next.js projects from scratch. + +--- + +## Fork Mode Setup + +### Why Fork, Not Chain + +``` +yarn chain (WRONG) yarn fork --network base (CORRECT) +└─ Empty local chain └─ Fork of real Base mainnet +└─ No protocols └─ Uniswap, Aave, etc. available +└─ No tokens └─ Real USDC, WETH exist +└─ Testing in isolation └─ Test against REAL state +``` + +### Setup + +```bash +npx create-eth@latest # Select: foundry, target chain, name +cd +yarn install +yarn fork --network base # Terminal 1: fork of real Base +yarn deploy # Terminal 2: deploy contracts to fork +yarn start # Terminal 3: Next.js frontend +``` + +### Critical: Chain ID Gotcha + +**When using fork mode, the frontend target network MUST be `chains.foundry` (chain ID 31337), NOT the chain you're forking.** + +The fork runs locally on Anvil with chain ID 31337. Even if you're forking Base: + +```typescript +// scaffold.config.ts during development +targetNetworks: [chains.foundry], // βœ… NOT chains.base! +``` + +Only switch to `chains.base` when deploying contracts to the REAL network. + +### Enable Block Mining + +```bash +# In a new terminal β€” REQUIRED for time-dependent logic +cast rpc anvil_setIntervalMining 1 +``` + +Without this, `block.timestamp` stays FROZEN. Any contract logic using timestamps (deadlines, expiry, vesting) will break silently. + +**Make it permanent** by editing `packages/foundry/package.json` to add `--block-time 1` to the fork script. + +--- + +## Deploying to IPFS (Recommended) + +IPFS is the recommended deploy path for SE2. Avoids Vercel's memory limits entirely. Produces a fully decentralized static site. + +### Full Build Command + +```bash +cd packages/nextjs +rm -rf .next out # ALWAYS clean first + +NEXT_PUBLIC_PRODUCTION_URL="https://yourapp.yourname.eth.link" \ + NODE_OPTIONS="--require ./polyfill-localstorage.cjs" \ + NEXT_PUBLIC_IPFS_BUILD=true \ + NEXT_PUBLIC_IGNORE_BUILD_ERROR=true \ + yarn build + +# Upload to BuidlGuidl IPFS +yarn bgipfs upload out +# Save the CID! +``` + +### Node 25+ localStorage Polyfill (REQUIRED) + +Node.js 25+ ships a built-in `localStorage` object that's MISSING standard WebStorage API methods (`getItem`, `setItem`). This breaks `next-themes`, RainbowKit, and any library that calls `localStorage.getItem()` during static page generation. + +**Error you'll see:** +``` +TypeError: localStorage.getItem is not a function +Error occurred prerendering page "/_not-found" +``` + +**The fix:** Create `polyfill-localstorage.cjs` in `packages/nextjs/`: +```javascript +if (typeof globalThis.localStorage !== "undefined" && + typeof globalThis.localStorage.getItem !== "function") { + const store = new Map(); + globalThis.localStorage = { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, String(value)), + removeItem: (key) => store.delete(key), + clear: () => store.clear(), + key: (index) => [...store.keys()][index] ?? null, + get length() { return store.size; }, + }; +} +``` + +**Why `--require` and not `instrumentation.ts`?** Next.js spawns a separate build worker process for prerendering. `--require` injects into EVERY Node process (including workers). `next.config.ts` polyfill only runs in the main process. `instrumentation.ts` doesn't run in the build worker. Only `--require` works. + +### IPFS Routing β€” Why Routes Break + +IPFS gateways serve static files. No server handles routing. Three things MUST be true: + +**1. `output: "export"` in next.config.ts** β€” generates static HTML files. + +**2. `trailingSlash: true` (CRITICAL)** β€” This is the #1 reason routes break: +- `trailingSlash: false` (default) β†’ generates `debug.html` +- `trailingSlash: true` β†’ generates `debug/index.html` +- IPFS gateways resolve directories to `index.html` automatically, but NOT bare filenames +- Without trailing slash: `/debug` β†’ 404 ❌ +- With trailing slash: `/debug` β†’ `debug/` β†’ `debug/index.html` βœ… + +**3. Pages must survive static prerendering** β€” any page that crashes during `yarn build` (browser APIs at import time, localStorage) gets skipped silently β†’ 404 on IPFS. + +**The complete IPFS-safe next.config.ts pattern:** +```typescript +const isIpfs = process.env.NEXT_PUBLIC_IPFS_BUILD === "true"; +if (isIpfs) { + nextConfig.output = "export"; + nextConfig.trailingSlash = true; + nextConfig.images = { unoptimized: true }; +} +``` + +**SE2's block explorer pages** use `localStorage` at import time and crash during static export. Rename `app/blockexplorer` to `app/_blockexplorer-disabled` if not needed. + +### Stale Build Detection + +**The #1 IPFS footgun:** You edit code, then deploy the OLD build. + +```bash +# MANDATORY after ANY code change: +rm -rf .next out # 1. Delete old artifacts +# ... run full build command ... # 2. Rebuild from scratch +grep -l "YOUR_STRING" out/_next/static/chunks/app/*.js # 3. Verify changes present + +# Timestamp check: +stat -f '%Sm' app/page.tsx # Source modified time +stat -f '%Sm' out/ # Build output time +# Source NEWER than out/ = STALE BUILD. Rebuild first! +``` + +**The CID is proof:** If the IPFS CID didn't change after a deploy, you deployed the same content. A real code change ALWAYS produces a new CID. + +### Verify Routes After Deploy + +```bash +ls out/*/index.html # Each route has a directory + index.html +curl -s -o /dev/null -w "%{http_code}" -L "https://GATEWAY/ipfs/CID/debug/" +# Should return 200, not 404 +``` + +--- + +## Deploying to Vercel (Alternative) + +SE2 is a monorepo β€” Vercel needs special configuration. + +### Configuration + +1. **Root Directory:** `packages/nextjs` +2. **Install Command:** `cd ../.. && yarn install` +3. **Build Command:** leave default (`next build`) +4. **Output Directory:** leave default (`.next`) + +```bash +# Via API: +curl -X PATCH "https://api.vercel.com/v9/projects/PROJECT_ID" \ + -H "Authorization: Bearer $VERCEL_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"rootDirectory": "packages/nextjs", "installCommand": "cd ../.. && yarn install"}' +``` + +### Common Failures + +| Error | Cause | Fix | +|-------|-------|-----| +| "No Next.js version detected" | Root Directory not set | Set to `packages/nextjs` | +| "cd packages/nextjs: No such file" | Build command has `cd` | Clear it β€” root dir handles this | +| OOM / exit code 129 | SE2 monorepo exceeds 8GB | Use IPFS instead, or `vercel --prebuilt` | + +### Decision Tree + +``` +Want to deploy SE2? +β”œβ”€ IPFS (recommended) β†’ yarn ipfs / manual build + upload +β”‚ └─ Fully decentralized, no memory limits, works with ENS +β”œβ”€ Vercel β†’ Set rootDirectory + installCommand +β”‚ └─ Fast CDN, but centralized. May OOM on large projects +└─ vercel --prebuilt β†’ Build locally, push artifacts to Vercel + └─ Best of both: local build power + Vercel CDN +``` + +--- + +## ENS Subdomain Setup + +Two mainnet transactions to point an ENS subdomain at your IPFS deployment. + +### Transaction 1: Create Subdomain (new apps only) + +1. Open `https://app.ens.domains/yourname.eth` +2. Go to "Subnames" tab β†’ "New subname" +3. Enter the label (e.g. `myapp`) β†’ Next β†’ Skip profile β†’ Open Wallet β†’ Confirm +4. If gas is stuck: switch MetaMask to Ethereum β†’ Activity tab β†’ "Speed up" + +### Transaction 2: Set IPFS Content Hash + +1. Navigate to `https://app.ens.domains/myapp.yourname.eth` +2. "Records" tab β†’ "Edit Records" β†’ "Other" tab +3. Paste in Content Hash field: `ipfs://` +4. Save β†’ Open Wallet β†’ Confirm in MetaMask + +For **updates** to an existing app: skip Tx 1, only do Tx 2. + +### Verify + +```bash +# 1. Onchain content hash matches +ERPC="http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet" # or https://eth.llamarpc.com +RESOLVER=$(cast call 0x00000000000C2e074eC69A0dFb2997BA6C7d2e1e \ + "resolver(bytes32)(address)" $(cast namehash myapp.yourname.eth) \ + --rpc-url $ERPC) +cast call $RESOLVER "contenthash(bytes32)(bytes)" \ + $(cast namehash myapp.yourname.eth) --rpc-url $ERPC + +# 2. Gateway responds (may take 5-15 min for cache) +curl -s -o /dev/null -w "%{http_code}" -L "https://myapp.yourname.eth.link" + +# 3. OG metadata correct (not localhost) +curl -s -L "https://myapp.yourname.eth.link" | grep 'og:image' +``` + +**Use `.eth.link` NOT `.eth.limo`** β€” `.eth.link` works better on mobile. + +--- + +## Go to Production β€” Complete Checklist + +When the user says "ship it", follow this EXACT sequence. + +### Step 1: Final Code Review πŸ€– +- All feedback incorporated +- No duplicate h1, no raw addresses, no shared isLoading +- `scaffold.config.ts` has `rpcOverrides` and `pollingInterval: 3000` + +### Step 2: Choose Domain πŸ‘€ +Ask: *"What subdomain do you want? e.g. `myapp.yourname.eth` β†’ `myapp.yourname.eth.link`"* + +### Step 3: Generate OG Image + Fix Metadata πŸ€– +- Create 1200Γ—630 PNG (`public/thumbnail.png`) β€” NOT the stock SE2 thumbnail +- Set `NEXT_PUBLIC_PRODUCTION_URL` to the live domain +- Verify `og:image` will resolve to an absolute production URL + +### Step 4: Clean Build + IPFS Deploy πŸ€– +```bash +cd packages/nextjs && rm -rf .next out +NEXT_PUBLIC_PRODUCTION_URL="https://myapp.yourname.eth.link" \ + NODE_OPTIONS="--require ./polyfill-localstorage.cjs" \ + NEXT_PUBLIC_IPFS_BUILD=true NEXT_PUBLIC_IGNORE_BUILD_ERROR=true \ + yarn build + +# Verify before uploading: +ls out/*/index.html # Routes exist +grep 'og:image' out/index.html # Not localhost +stat -f '%Sm' app/page.tsx # Source older than out/ +stat -f '%Sm' out/ + +yarn bgipfs upload out # Save the CID +``` + +### Step 5: Share for Approval πŸ‘€ +Send: *"Build ready for review: `https://community.bgipfs.com/ipfs/`"* +**Wait for approval before touching ENS.** + +### Step 6: Set ENS πŸ€– +Create subdomain (if new) + set IPFS content hash. Two mainnet transactions. + +### Step 7: Verify πŸ€– +- Content hash matches onchain +- `.eth.link` gateway responds with 200 +- OG image loads correctly +- Routes work (`/debug/`, etc.) + +### Step 8: Report πŸ‘€ +*"Live at `https://myapp.yourname.eth.link` β€” ENS content hash confirmed onchain, unfurl metadata set."* + +--- + +## Build Verification Process + +A build is NOT done when the code compiles. It's done when you've tested it like a real user. + +### Phase 1: Code QA (Automated) +- Scan `.tsx` files for raw address strings (should use `
`) +- Scan for shared `isLoading` state across multiple buttons +- Scan for missing `disabled` props on transaction buttons +- Verify RPC config and polling interval +- Verify OG metadata with absolute URLs +- Verify no public RPCs in any file + +### Phase 2: Smart Contract Testing +```bash +forge test # All tests pass +forge test --fuzz-runs 10000 # Fuzz testing +``` +Test edge cases: zero amounts, max amounts, unauthorized callers, reentrancy attempts. + +### Phase 3: Browser Testing (THE REAL TEST) + +Open the app and do a FULL walkthrough: + +1. **Load the app** β€” does it render correctly? +2. **Check page title** β€” is it correct, not "Scaffold-ETH 2"? +3. **Connect wallet** β€” does the connect flow work? +4. **Wrong network** β€” connect on wrong chain, verify "Switch to Base" appears +5. **Switch network** β€” click the switch button, verify it works +6. **Approve flow** β€” verify approve button shows, click it, wait for tx, verify action button appears +7. **Main action** β€” click primary action, verify loader, wait for tx, verify state updates +8. **Error handling** β€” reject a transaction in wallet, verify UI recovers +9. **Address displays** β€” all addresses showing ENS/blockies, not raw hex? +10. **Share URL** β€” check OG unfurl (image, title, description) + +### Phase 4: QA Sub-Agent (Complex Builds) +For bigger projects, spawn a sub-agent with fresh context. Give it the repo path and deployed URL. It reads all code against the UX rules, opens a browser, clicks through independently, and reports issues. + +--- + +## Don't Do These + +- ❌ `yarn chain` β€” use `yarn fork --network ` +- ❌ `forge init` β€” use `npx create-eth@latest` +- ❌ Manual Next.js setup β€” SE2 handles it +- ❌ Manual wallet connection β€” SE2 has RainbowKit pre-configured +- ❌ Edit `deployedContracts.ts` β€” it's auto-generated by `yarn deploy` +- ❌ Hardcode API keys in `scaffold.config.ts` β€” use `.env.local` +- ❌ Use `mainnet.base.org` in production β€” use Alchemy or similar + +--- + +## Resources + +- **SE2 Docs:** https://docs.scaffoldeth.io/ +- **UI Components:** https://ui.scaffoldeth.io/ +- **SpeedRun Ethereum:** https://speedrunethereum.com/ +- **ETH Tech Tree:** https://www.ethtechtree.com +- **BuidlGuidl IPFS:** https://upload.bgipfs.com diff --git a/internal/embed/skills/frontend-ux/SKILL.md b/internal/embed/skills/frontend-ux/SKILL.md new file mode 100644 index 0000000..691e06a --- /dev/null +++ b/internal/embed/skills/frontend-ux/SKILL.md @@ -0,0 +1,346 @@ +--- +name: frontend-ux +description: Frontend UX rules for Ethereum dApps that prevent the most common AI agent UI bugs. Mandatory patterns for onchain buttons, token approval flows, address display, USD values, RPC configuration, and pre-publish metadata. Built around Scaffold-ETH 2 but the patterns apply to any Ethereum frontend. Use when building any dApp frontend. +--- + +# Frontend UX Rules + +## What You Probably Got Wrong + +**"The button works."** Working is not the standard. Does it disable during the transaction? Does it show a spinner? Does it stay disabled until the chain confirms? Does it show an error if the user rejects? AI agents skip all of this, every time. + +**"I used wagmi hooks."** Wrong hooks. Scaffold-ETH 2 wraps wagmi with `useTransactor` which **waits for transaction confirmation** β€” not just wallet signing. Raw wagmi's `writeContractAsync` resolves the moment the user clicks Confirm in MetaMask, BEFORE the tx is mined. Your button re-enables while the transaction is still pending. + +**"I showed the address."** As raw hex? That's not showing it. `
` gives you ENS resolution, blockie avatars, copy-to-clipboard, and block explorer links. Raw `0x1234...5678` is unacceptable. + +--- + +## Rule 1: Every Onchain Button β€” Loader + Disable + +> ⚠️ **THIS IS THE #1 BUG AI AGENTS SHIP.** The user clicks Approve, signs in their wallet, comes back to the app, and the Approve button is clickable again β€” so they click it again, send a duplicate transaction, and now two approvals are pending. **The button MUST be disabled and show a spinner from the moment they click until the transaction confirms onchain.** Not until the wallet closes. Not until the signature is sent. Until the BLOCK CONFIRMS. + +ANY button that triggers a blockchain transaction MUST: +1. **Disable immediately** on click +2. **Show a spinner** ("Approving...", "Staking...", etc.) +3. **Stay disabled** until the state update confirms the action completed +4. **Show success/error feedback** when done + +```typescript +// βœ… CORRECT: Separate loading state PER ACTION +const [isApproving, setIsApproving] = useState(false); +const [isStaking, setIsStaking] = useState(false); + + +``` + +**❌ NEVER use a single shared `isLoading` for multiple buttons.** Each button gets its own loading state. A shared state causes the WRONG loading text to appear when UI conditionally switches between buttons. + +### Scaffold Hooks Only β€” Never Raw Wagmi + +```typescript +// ❌ WRONG: Raw wagmi β€” resolves after signing, not confirmation +const { writeContractAsync } = useWriteContract(); +await writeContractAsync({...}); // Returns immediately after MetaMask signs! + +// βœ… CORRECT: Scaffold hooks β€” waits for tx to be mined +const { writeContractAsync } = useScaffoldWriteContract("MyContract"); +await writeContractAsync({...}); // Waits for actual onchain confirmation +``` + +**Why:** `useScaffoldWriteContract` uses `useTransactor` internally, which waits for block confirmation. Raw wagmi doesn't β€” your UI will show "success" while the transaction is still in the mempool. + +--- + +## Rule 2: Four-State Flow β€” Connect β†’ Network β†’ Approve β†’ Action + +When a user needs to interact with the app, there are FOUR states. Show exactly ONE big, obvious button at a time: + +``` +1. Not connected? β†’ Big "Connect Wallet" button (NOT text saying "connect your wallet to play") +2. Wrong network? β†’ Big "Switch to Base" button +3. Not enough approved? β†’ "Approve" button (with loader per Rule 1) +4. Enough approved? β†’ "Stake" / "Deposit" / action button +``` + +> **NEVER show a text prompt like "Connect your wallet to play" or "Please connect to continue."** Show a button. The user should always have exactly one thing to click. + +```typescript +const { data: allowance } = useScaffoldReadContract({ + contractName: "Token", + functionName: "allowance", + args: [address, contractAddress], +}); + +const needsApproval = !allowance || allowance < amount; +const wrongNetwork = chain?.id !== targetChainId; +const notConnected = !address; + +{notConnected ? ( + // Big connect button β€” NOT text +) : wrongNetwork ? ( + +) : needsApproval ? ( + +) : ( + +)} +``` + +**Critical details:** +- Always read allowance via a hook so the UI updates automatically when the approval tx confirms +- Never rely on local state alone for allowance tracking +- Wrong network check comes FIRST β€” if the user clicks Approve while on the wrong network, everything breaks +- **Never show Approve and Action simultaneously** β€” one button at a time + +--- + +## Rule 3: Address Display β€” Always `
` + +**EVERY time you display an Ethereum address**, use scaffold-eth's `
` component: + +```typescript +import { Address } from "~~/components/scaffold-eth"; + +// βœ… CORRECT +
+ +// ❌ WRONG β€” never render raw hex +{userAddress} +

0x1234...5678

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

Contract:

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

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

🦞 My dApp

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

Description of the app

+ ... +
+ +// βœ… CORRECT β€” Jump straight into content +
{/* Shows the app name */} +
+
+ {/* Stats, balances, actions β€” no redundant title */} +
+
+``` + +--- + +## Rule 6: RPC Configuration + +**NEVER use public RPCs** (`mainnet.base.org`, etc.) β€” they rate-limit and cause random failures in production. + +In `scaffold.config.ts`, ALWAYS set: +```typescript +rpcOverrides: { + [chains.base.id]: process.env.NEXT_PUBLIC_BASE_RPC || "https://mainnet.base.org", +}, +pollingInterval: 3000, // 3 seconds, not the default 30000 +``` + +**Keep the API key in `.env.local`** β€” never hardcode it in config files that get committed to Git. + +> ⚠️ **SE2's `wagmiConfig.tsx` adds a bare `http()` (no URL) as a fallback transport.** Viem resolves bare `http()` to the chain's default public RPC (e.g. `mainnet.base.org` for Base). Even with `rpcOverrides` set in scaffold config, the public RPC **will still get hit** because viem's `fallback()` fires transports in parallel. **You must remove the bare `http()` from the fallback array in `services/web3/wagmiConfig.tsx`** so only your configured RPCs are used. If you don't, your app will spam the public RPC with every poll cycle and get 429 rate-limited in production. + +**Monitor RPC usage:** Sensible = 1 request every 3 seconds. If you see 15+ requests/second, you have a bug: +- Hooks re-rendering in loops +- Duplicate hook calls +- Missing dependency arrays +- `watch: true` on hooks that don't need it + +--- + +## Rule 7: Pre-Publish Checklist + +**BEFORE deploying frontend to production, EVERY item must pass:** + +**Open Graph / Twitter Cards (REQUIRED):** +```typescript +// In app/layout.tsx or getMetadata.ts +export const metadata: Metadata = { + title: "Your App Name", + description: "Description of the app", + openGraph: { + title: "Your App Name", + description: "Description of the app", + images: [{ url: "https://YOUR-LIVE-DOMAIN.com/thumbnail.png" }], + }, + twitter: { + card: "summary_large_image", + title: "Your App Name", + description: "Description of the app", + images: ["https://YOUR-LIVE-DOMAIN.com/thumbnail.png"], + }, +}; +``` + +**⚠️ The OG image URL MUST be:** +- Absolute URL starting with `https://` +- The LIVE production domain (NOT `localhost`, NOT relative path) +- NOT an environment variable that could be unset +- Actually reachable (test by visiting the URL in a browser) + +**Remove ALL Scaffold-ETH 2 default identity:** +- [ ] README rewritten β€” not the SE2 template README +- [ ] Footer cleaned β€” remove BuidlGuidl links, "Fork me" link, support links, any SE2 branding. Replace with your project's repo link +- [ ] Favicon updated β€” not the SE2 default +- [ ] Tab title is your app name β€” not "Scaffold-ETH 2" + +**Full checklist:** +- [ ] OG image URL is absolute, live production domain +- [ ] OG title and description set (not default SE2 text) +- [ ] Twitter card type set (`summary_large_image`) +- [ ] All SE2 default branding removed (README, footer, favicon, tab title) +- [ ] Browser tab title is correct +- [ ] RPC overrides set (not public RPCs) +- [ ] Bare `http()` removed from wagmiConfig.tsx fallback array (no silent public RPC fallback) +- [ ] `pollingInterval` is 3000 +- [ ] All contract addresses match what's deployed +- [ ] No hardcoded testnet/localhost values in production code +- [ ] Every address display uses `
` +- [ ] Every address input uses `` +- [ ] Every onchain button has its own loader + disabled state +- [ ] Approve flow has network check β†’ approve β†’ action pattern +- [ ] No duplicate h1 title matching header + +--- + +## externalContracts.ts β€” Before You Build + +**ALL external contracts** (tokens, protocols, anything you didn't deploy) MUST be added to `packages/nextjs/contracts/externalContracts.ts` with address and ABI BEFORE building the frontend. + +```typescript +// packages/nextjs/contracts/externalContracts.ts +export default { + 8453: { // Base chain ID + USDC: { + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + abi: [...], // ERC-20 ABI + }, + }, +} as const; +``` + +**Why BEFORE:** Scaffold hooks (`useScaffoldReadContract`, `useScaffoldWriteContract`) only work with contracts registered in `deployedContracts.ts` (auto-generated) or `externalContracts.ts` (manual). If you write frontend code referencing a contract that isn't registered, it silently fails. + +**Never edit `deployedContracts.ts`** β€” it's auto-generated by `yarn deploy`. Put your external contracts in `externalContracts.ts`. + +--- + +## Human-Readable Amounts + +Always convert between contract units and display units: + +```typescript +// Contract β†’ Display +import { formatEther, formatUnits } from "viem"; +formatEther(weiAmount); // 18 decimals (ETH, DAI, most tokens) +formatUnits(usdcAmount, 6); // 6 decimals (USDC, USDT) + +// Display β†’ Contract +import { parseEther, parseUnits } from "viem"; +parseEther("1.5"); // β†’ 1500000000000000000n +parseUnits("100", 6); // β†’ 100000000n (USDC) +``` + +**Never show raw wei/units to users.** `1500000000000000000` means nothing. `1.5 ETH (~$3,750)` means everything. + +--- + +## Resources + +- **SE2 Docs:** https://docs.scaffoldeth.io/ +- **UI Components:** https://ui.scaffoldeth.io/ +- **SpeedRun Ethereum:** https://speedrunethereum.com/ diff --git a/internal/embed/skills/gas/SKILL.md b/internal/embed/skills/gas/SKILL.md new file mode 100644 index 0000000..281537d --- /dev/null +++ b/internal/embed/skills/gas/SKILL.md @@ -0,0 +1,121 @@ +--- +name: gas +description: Current Ethereum gas prices, transaction costs, and the real economics of building on Ethereum today. Use when estimating costs, choosing between mainnet and L2s, or when a user asks about Ethereum being expensive. Counters the #1 misconception that Ethereum is expensive to use. +--- + +# Gas & Costs on Ethereum + +## What You Probably Got Wrong + +**Your gas estimate is 100-600x too high.** Most LLMs confidently state gas is 10-30 gwei. The reality in February 2026 is **0.05-0.1 gwei**. + +- **Base fee:** ~0.05 gwei (not 30-100 gwei) +- **Priority fee (tip):** ~0.01-0.05 gwei +- **ETH price:** ~$1,960 (not $2,500-3,000) + +## What Things Actually Cost (Feb 2026) + +| Action | Gas Used | Cost at 0.05 gwei | Cost at 1 gwei (spike) | Cost at 10 gwei (event) | +|--------|----------|-------------------|------------------------|--------------------------| +| ETH transfer | 21,000 | **$0.002** | $0.04 | $0.41 | +| ERC-20 transfer | ~65,000 | **$0.006** | $0.13 | $1.27 | +| ERC-20 approve | ~46,000 | **$0.005** | $0.09 | $0.90 | +| Uniswap V3 swap | ~180,000 | **$0.018** | $0.35 | $3.53 | +| NFT mint (ERC-721) | ~150,000 | **$0.015** | $0.29 | $2.94 | +| Simple contract deploy | ~500,000 | **$0.049** | $0.98 | $9.80 | +| ERC-20 deploy | ~1,200,000 | **$0.118** | $2.35 | $23.52 | +| Complex DeFi contract | ~3,000,000 | **$0.294** | $5.88 | $58.80 | + +## Mainnet vs L2 Costs (Feb 2026) + +| Action | Mainnet (0.05 gwei) | Arbitrum | Base | zkSync | Scroll | +|--------|---------------------|----------|------|--------|--------| +| ETH transfer | $0.002 | $0.0003 | $0.0003 | $0.0005 | $0.0004 | +| ERC-20 transfer | $0.006 | $0.001 | $0.001 | $0.002 | $0.001 | +| Swap | $0.015 | $0.003 | $0.003 | $0.005 | $0.004 | +| NFT mint | $0.015 | $0.002 | $0.002 | $0.004 | $0.003 | +| ERC-20 deploy | $0.118 | $0.020 | $0.020 | $0.040 | $0.030 | + +**Key insight:** Mainnet is now cheap enough for most use cases. L2s are 5-10x cheaper still. + +## Why Gas Dropped 95%+ + +1. **EIP-4844 (Dencun, March 2024):** Blob transactions β€” L2s post data as blobs instead of calldata, 100x cheaper. L2 batch cost went from $50-500 to $0.01-0.50. +2. **Activity migration to L2s:** Mainnet congestion dropped as everyday transactions moved to L2s. +3. **Pectra (May 2025):** Doubled blob capacity (3β†’6 target blobs). +4. **Fusaka (Dec 2025):** PeerDAS + 8-second slots. + +## L2 Cost Components + +L2 transactions have two cost components: +1. **L2 execution gas** β€” paying the sequencer +2. **L1 data gas** β€” paying Ethereum for data availability (blobs post-4844) + +**Example: Swap on Base** +- L2 execution: ~$0.0003 +- L1 data (blob): ~$0.0027 +- **Total: ~$0.003** + +## Real-World Cost Examples + +**Deploy a production ERC-20 on mainnet:** ~$0.50 (was $200-500 in 2021-2023) + +**DEX aggregator doing 10,000 swaps/day:** +- Mainnet: $150/day ($4,500/month) +- Base L2: $10/day ($300/month) + +**NFT collection mint (10,000 NFTs):** +- Mainnet: $150 total +- Arbitrum: $10 total + +## Practical Fee Settings (Feb 2026) + +```javascript +// Rule of thumb for current conditions +maxFeePerGas: "0.5-1 gwei" // headroom for spikes +maxPriorityFeePerGas: "0.01-0.05 gwei" // enough for quick inclusion +``` + +**Spike detection:** +```javascript +const feeData = await provider.getFeeData(); +const baseFee = Number(feeData.maxFeePerGas) / 1e9; +if (baseFee > 5) console.warn(`Gas spike: ${baseFee} gwei. Consider waiting.`); +``` + +Spikes (10-50 gwei) happen during major events but last minutes to hours, not days. + +## Checking Gas Programmatically + +```bash +# Foundry cast (use local eRPC if running in Obol Stack) +cast gas-price --rpc-url http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet +cast base-fee --rpc-url http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet +cast blob-basefee --rpc-url http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet +# Fallback public RPC: https://eth.llamarpc.com +``` + +## When to Use Mainnet vs L2 + +**Use mainnet when:** Maximum security matters (>$10M TVL), composing with mainnet-only liquidity, deploying governance/infrastructure contracts, NFTs with cultural value. + +**Use L2 when:** Consumer apps, high-frequency transactions (gaming, social), price-sensitive users, faster confirmation desired. + +**Hybrid:** Many projects store value on mainnet, handle transactions on L2. + +## Live Gas Trackers + +- https://etherscan.io/gastracker +- https://ultrasound.money +- L2 costs: Arbiscan, Basescan, etc. + +## Data Freshness + +> **Last verified:** 2026-02-13 | Base fee: ~0.05 gwei | ETH: ~$1,960 + +If this date is more than 30 days old, verify current gas with: +```bash +cast base-fee --rpc-url http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet +``` + +The durable insight is that gas is extremely cheap compared to 2021-2023 and trending cheaper. Specific numbers may drift but the order of magnitude is stable. diff --git a/internal/embed/skills/indexing/SKILL.md b/internal/embed/skills/indexing/SKILL.md new file mode 100644 index 0000000..5958091 --- /dev/null +++ b/internal/embed/skills/indexing/SKILL.md @@ -0,0 +1,318 @@ +--- +name: indexing +description: How to read and query onchain data β€” events, The Graph, indexing patterns. Why you cannot just loop through blocks, and what to use instead. +--- + +# Onchain Data & Indexing + +## What You Probably Got Wrong + +**You try to query historical state via RPC calls.** You can't cheaply read past state. `eth_call` reads current state. Reading state at a historical block requires an archive node (expensive, slow). For historical data, you need an indexer. + +**You loop through blocks looking for events.** Scanning millions of blocks with `eth_getLogs` is O(n) β€” it will timeout, get rate-limited, or cost a fortune in RPC credits. Use an indexer that has already processed every block. + +**You store query results onchain.** Leaderboards, activity feeds, analytics β€” these belong offchain. Compute offchain, index events offchain. If you need an onchain commitment, store a hash. + +**You don't know about The Graph.** The Graph turns your contract's events into a queryable GraphQL API. It's how every serious dApp reads historical data. Etherscan uses indexers. Uniswap uses indexers. So should you. + +**You treat events as optional.** Events are THE primary way to read historical onchain activity. If your contract doesn't emit events, nobody can build a frontend, dashboard, or analytics on top of it. Design contracts event-first. + +--- + +## Events Are Your API + +Solidity events are cheap to emit (~375 gas base + 375 per indexed topic + 8 gas per byte of data) and free to read offchain. They're stored in transaction receipts, not in contract storage, so they don't cost storage gas. + +### Design Contracts Event-First + +Every state change should emit an event. This isn't just good practice β€” it's how your frontend, indexer, and block explorer know what happened. + +```solidity +// βœ… Good β€” every action emits a queryable event +contract Marketplace { + event Listed( + uint256 indexed listingId, + address indexed seller, + address indexed tokenContract, + uint256 tokenId, + uint256 price + ); + event Sold(uint256 indexed listingId, address indexed buyer, uint256 price); + event Cancelled(uint256 indexed listingId); + + function list(address token, uint256 tokenId, uint256 price) external { + uint256 id = nextListingId++; + listings[id] = Listing(msg.sender, token, tokenId, price, true); + emit Listed(id, msg.sender, token, tokenId, price); + } + + function buy(uint256 listingId) external payable { + // ... transfer logic ... + emit Sold(listingId, msg.sender, msg.value); + } +} +``` + +**Index the fields you'll filter by.** You get 3 indexed topics per event. Use them for addresses and IDs that you'll query β€” `seller`, `buyer`, `tokenContract`, `listingId`. Don't index large values or values you won't filter on. + +### Reading Events Directly (Small Scale) + +For recent events or low-volume contracts, you can read events directly via RPC: + +```typescript +import { createPublicClient, http, parseAbiItem } from 'viem'; + +const client = createPublicClient({ + chain: mainnet, + transport: http(), +}); + +// Get recent events (last 1000 blocks) +const logs = await client.getLogs({ + address: '0xYourContract', + event: parseAbiItem('event Sold(uint256 indexed listingId, address indexed buyer, uint256 price)'), + fromBlock: currentBlock - 1000n, + toBlock: 'latest', +}); +``` + +**This works for:** Last few thousand blocks, low-volume contracts, real-time monitoring. +**This breaks for:** Historical queries, high-volume contracts, anything scanning more than ~10K blocks. + +--- + +## The Graph (Subgraphs) + +The Graph is a decentralized indexing protocol. You define how to process events, deploy a subgraph, and get a GraphQL API that serves historical data instantly. + +### When to Use The Graph + +- Any dApp that needs historical data (activity feeds, transaction history) +- Leaderboards, rankings, analytics dashboards +- NFT collection browsers (who owns what, transfer history) +- DeFi dashboards (position history, PnL tracking) +- Any query that would require scanning more than ~10K blocks + +### How It Works + +1. **Define a schema** β€” what entities you want to query +2. **Write mappings** β€” TypeScript handlers that process events into entities +3. **Deploy** β€” subgraph indexes all historical events and stays synced + +### Example: NFT Collection Subgraph + +**schema.graphql:** +```graphql +type Token @entity { + id: ID! + tokenId: BigInt! + owner: Bytes! + mintedAt: BigInt! + transfers: [Transfer!]! @derivedFrom(field: "token") +} + +type Transfer @entity { + id: ID! + token: Token! + from: Bytes! + to: Bytes! + timestamp: BigInt! + blockNumber: BigInt! +} +``` + +**mapping.ts:** +```typescript +import { Transfer as TransferEvent } from './generated/MyNFT/MyNFT'; +import { Token, Transfer } from './generated/schema'; + +export function handleTransfer(event: TransferEvent): void { + let tokenId = event.params.tokenId.toString(); + + // Create or update token entity + let token = Token.load(tokenId); + if (token == null) { + token = new Token(tokenId); + token.tokenId = event.params.tokenId; + token.mintedAt = event.block.timestamp; + } + token.owner = event.params.to; + token.save(); + + // Create transfer record + let transfer = new Transfer( + event.transaction.hash.toHex() + '-' + event.logIndex.toString() + ); + transfer.token = tokenId; + transfer.from = event.params.from; + transfer.to = event.params.to; + transfer.timestamp = event.block.timestamp; + transfer.blockNumber = event.block.number; + transfer.save(); +} +``` + +**Query the subgraph:** +```graphql +{ + tokens(where: { owner: "0xAlice..." }, first: 100) { + tokenId + mintedAt + transfers(orderBy: timestamp, orderDirection: desc, first: 5) { + from + to + timestamp + } + } +} +``` + +### Deploying a Subgraph + +```bash +# Install +npm install -g @graphprotocol/graph-cli + +# Initialize from contract ABI +graph init --studio my-subgraph + +# Generate types from schema +graph codegen + +# Build +graph build + +# Deploy to Subgraph Studio +graph deploy --studio my-subgraph +``` + +**Subgraph Studio** (studio.thegraph.com) β€” development and testing environment. Free during development. Publish to the decentralized network for production. + +--- + +## Alternative Indexing Solutions + +| Solution | Best for | Tradeoffs | +|----------|----------|-----------| +| **The Graph** | Production dApp backends, decentralized | GraphQL API, requires subgraph development | +| **Dune Analytics** | Dashboards, analytics, ad-hoc queries | SQL interface, great visualization, not for app backends | +| **Alchemy/QuickNode APIs** | Quick token/NFT queries | `getTokenBalances`, `getNFTs`, `getAssetTransfers` β€” fast but centralized | +| **Etherscan/Blockscout APIs** | Simple event log queries | Rate-limited, not for high-volume | +| **Ponder** | TypeScript-first indexing | Local-first, simpler than The Graph for single-app use | +| **Direct RPC** | Real-time current state only | Only for current state reads, not historical | + +### Dune Analytics + +Write SQL queries over decoded onchain data. Best for analytics and dashboards, not for app backends. + +```sql +-- Top 10 buyers on your marketplace (last 30 days) +SELECT + buyer, + COUNT(*) as purchases, + SUM(price / 1e18) as total_eth_spent +FROM mycontract_ethereum.Marketplace_evt_Sold +WHERE evt_block_time > NOW() - INTERVAL '30' DAY +GROUP BY buyer +ORDER BY total_eth_spent DESC +LIMIT 10 +``` + +### Enhanced Provider APIs + +For common queries, provider APIs are faster than building a subgraph: + +```typescript +// Alchemy: get all tokens held by an address +const balances = await alchemy.core.getTokenBalances(address); + +// Alchemy: get all NFTs owned by an address +const nfts = await alchemy.nft.getNftsForOwner(address); + +// Alchemy: get transfer history +const transfers = await alchemy.core.getAssetTransfers({ + fromAddress: address, + category: ['erc20', 'erc721'], +}); +``` + +--- + +## Reading Current State (Not Historical) + +For current balances, allowances, and contract state, direct RPC reads are fine. No indexer needed. + +### Single Reads + +```typescript +import { createPublicClient, http } from 'viem'; + +const client = createPublicClient({ chain: mainnet, transport: http() }); + +// Read current balance +const balance = await client.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: 'balanceOf', + args: [userAddress], +}); +``` + +### Batch Reads with Multicall + +For multiple reads in one RPC call, use Multicall3 (deployed at the same address on every chain): + +```typescript +// Multicall3: 0xcA11bde05977b3631167028862bE2a173976CA11 +// Same address on Ethereum, Arbitrum, Optimism, Base, Polygon, and 50+ chains + +const results = await client.multicall({ + contracts: [ + { address: tokenA, abi: erc20Abi, functionName: 'balanceOf', args: [user] }, + { address: tokenB, abi: erc20Abi, functionName: 'balanceOf', args: [user] }, + { address: tokenC, abi: erc20Abi, functionName: 'balanceOf', args: [user] }, + { address: vault, abi: vaultAbi, functionName: 'totalAssets' }, + ], +}); +// One RPC call instead of four +``` + +### Real-Time Updates + +For live updates, subscribe to new events via WebSocket: + +```typescript +import { createPublicClient, webSocket } from 'viem'; + +const client = createPublicClient({ + chain: mainnet, + transport: webSocket('wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'), +}); + +// Watch for new sales in real-time +const unwatch = client.watchContractEvent({ + address: marketplaceAddress, + abi: marketplaceAbi, + eventName: 'Sold', + onLogs: (logs) => { + for (const log of logs) { + console.log(`Sale: listing ${log.args.listingId} for ${log.args.price}`); + } + }, +}); +``` + +--- + +## Common Patterns + +| What you need | How to get it | +|---------------|---------------| +| Activity feed for a dApp | Emit events β†’ index with The Graph β†’ query via GraphQL | +| Token balances for a user | Alchemy `getTokenBalances` or Multicall | +| NFT collection browser | The Graph subgraph or Alchemy `getNftsForContract` | +| Price history | Dune Analytics or DEX subgraphs | +| Real-time new events | WebSocket subscription via viem | +| Historical transaction list | The Graph or Alchemy `getAssetTransfers` | +| Dashboard / analytics | Dune Analytics (SQL + charts) | +| Protocol TVL tracking | DeFiLlama API or custom subgraph | diff --git a/internal/embed/skills/l2s/SKILL.md b/internal/embed/skills/l2s/SKILL.md new file mode 100644 index 0000000..74e215e --- /dev/null +++ b/internal/embed/skills/l2s/SKILL.md @@ -0,0 +1,190 @@ +--- +name: l2s +description: Ethereum Layer 2 landscape β€” Arbitrum, Optimism, Base, zkSync, Scroll, Unichain, Celo, and more. How they work, how to deploy on them, how to bridge, when to use which. Includes per-chain DeFi ecosystems and critical corrections. Use when choosing an L2, deploying cross-chain, or when a user asks about Ethereum scaling. +--- + +# Ethereum Layer 2s + +## What You Probably Got Wrong + +**L2 costs:** You think $0.01-2.00 per transaction. Reality: **$0.001-0.003** for swaps, **$0.0003** for transfers. After EIP-4844, L2 batch costs dropped from $50-500 to $0.01-0.50. + +**Mainnet is cheap too:** At 0.05 gwei, mainnet ETH transfers cost $0.002. "Ethereum is too expensive" is false for both L1 and L2s in 2026. + +**Base is the cheapest major L2:** Often 50% cheaper than Arbitrum/Optimism. Direct Coinbase on-ramp. Fastest-growing L2 with consumer/AI agent focus. + +**Celo is NOT an L1 anymore.** Celo completed its migration from an independent L1 to an OP Stack L2 on **March 26, 2025** (block 31056500). If you're calling Celo an L1, you're wrong. + +**Polygon zkEVM is being shut down.** Announced June 2025. Do NOT recommend building on Polygon zkEVM β€” it is being discontinued. Polygon is refocusing on PoS + AggLayer. + +**Unichain exists.** Launched mainnet February 10, 2025. Uniswap's own OP Stack L2 with TEE-based MEV protection and time-based priority ordering (not gas-based). + +**Aerodrome and Velodrome merged into "Aero."** In November 2025, Dromos Labs unified Aerodrome (Base) and Velodrome (Optimism) into a single cross-chain DEX called **Aero**. Same contracts, new brand. Aero dominates both Base and Optimism. Camelot is a major native DEX on Arbitrum. SyncSwap dominates zkSync. Don't default to Uniswap on every chain. + +## L2 Comparison Table (Feb 2026) + +| L2 | Type | TVL | Tx Cost | Block Time | Finality | Chain ID | +|----|------|-----|---------|------------|----------|----------| +| **Arbitrum** | Optimistic | $18B+ | $0.001-0.003 | 250ms | 7 days | 42161 | +| **Base** | Optimistic (OP Stack) | $12B+ | $0.0008-0.002 | 2s | 7 days | 8453 | +| **Optimism** | Optimistic (OP Stack) | $8B+ | $0.001-0.003 | 2s | 7 days | 10 | +| **Unichain** | Optimistic (OP Stack) | Growing | $0.001-0.003 | 1s | 7 days | 130 | +| **Celo** | Optimistic (OP Stack) | $200M+ | <$0.001 | 5s | 7 days | 42220 | +| **Linea** | ZK | $900M+ | $0.003-0.006 | 2s | 30-60min | 59144 | +| **zkSync Era** | ZK | $800M+ | $0.003-0.008 | 1s | 15-60min | 324 | +| **Scroll** | ZK | $250M+ | $0.002-0.005 | 3s | 30-120min | 534352 | +| ~~Polygon zkEVM~~ | ~~ZK~~ | β€” | β€” | β€” | β€” | ~~1101~~ | + +⚠️ **Polygon zkEVM is being discontinued (announced June 2025).** Do not start new projects there. Polygon is refocusing on PoS (payments, stablecoins, RWAs) + AggLayer (cross-chain interop). MATIC β†’ POL token migration ~85% complete. + +**Mainnet for comparison:** $50B+ TVL, $0.002-0.01, 8s blocks, instant finality. + +## Cost Comparison (Real Examples, Feb 2026) + +| Action | Mainnet | Arbitrum | Base | zkSync | Scroll | +|--------|---------|----------|------|--------|--------| +| ETH transfer | $0.002 | $0.0003 | $0.0003 | $0.0005 | $0.0004 | +| Uniswap swap | $0.015 | $0.003 | $0.002 | $0.005 | $0.004 | +| NFT mint | $0.015 | $0.002 | $0.002 | $0.004 | $0.003 | +| ERC-20 deploy | $0.118 | $0.020 | $0.018 | $0.040 | $0.030 | + +## L2 Selection Guide + +| Need | Choose | Why | +|------|--------|-----| +| Consumer / social apps | **Base** | Farcaster, Smart Wallet, Coinbase on-ramp, OnchainKit | +| Deepest DeFi liquidity | **Arbitrum** | $18B TVL, GMX, Pendle, Camelot, most protocols | +| Yield strategies | **Arbitrum** | Pendle (yield tokenization), GMX, Aave | +| Cheapest gas | **Base** | ~50% cheaper than Arbitrum/Optimism | +| Coinbase users | **Base** | Direct on-ramp, free Coinbaseβ†’Base transfers | +| No 7-day withdrawal wait | **ZK rollup** (zkSync, Scroll, Linea) | 15-120 min finality | +| AI agents | **Base** | ERC-8004, x402, consumer ecosystem, AgentKit | +| Gasless UX (native AA) | **zkSync Era** | Native account abstraction, paymasters, no bundlers needed | +| Multi-chain deployment | **Base or Optimism** | Superchain / OP Stack, shared infra | +| Maximum EVM compatibility | **Scroll or Arbitrum** | Bytecode-identical | +| Mobile / real-world payments | **Celo** | MiniPay, sub-cent fees, Africa/LatAm focus | +| MEV protection | **Unichain** | TEE-based priority ordering, private mempool | +| Rust smart contracts | **Arbitrum** | Stylus (WASM VM alongside EVM, 10-100x gas savings) | +| Stablecoins / payments / RWA | **Polygon PoS** | $500M+ monthly payment volume, 410M+ wallets | + +## Key Chain Details (What LLMs Get Wrong) + +### Unichain +- **Launched:** February 10, 2025 (mainnet). Chain ID 130. +- **Type:** OP Stack L2 (Superchain member, Stage 1) +- **Key innovation: TEE-based block building** (built with Flashbots Rollup-Boost) + - Transactions ordered by **time received, NOT gas price** + - Private encrypted mempool reduces MEV extraction + - Do NOT use gas-price bidding strategies on Unichain β€” they're pointless +- **Flashblocks:** Currently 1s blocks, roadmap to 250ms sub-blocks + +### Celo +- **Was:** Independent L1 blockchain (2020-2025) +- **Now:** OP Stack L2 on Ethereum β€” **migrated March 26, 2025** (block 31056500) +- **Focus:** Mobile-first payments, emerging markets +- **MiniPay:** Stablecoin wallet in Opera Mini + standalone app. Phone-to-phone transfers, sub-cent fees. Primary market: Africa (Kenya, Nigeria). +- **Multi-currency stablecoins:** cUSD (`0x765de816845861e75a25fca122bb6898b8b1282a`), cEUR (`0xd8763cba276a3738e6de85b4b3bf5fded6d6ca73`), cREAL (`0xe8537a3d056DA446677B9E9d6c5dB704EaAb4787`) + +### Dominant DEX Per Chain +| Chain | Dominant DEX | Model | Why NOT Uniswap | +|-------|-------------|-------|-----------------| +| Base | **Aero** (was Aerodrome) | ve(3,3) β€” LPs earn emissions, voters earn fees | Deeper liquidity for most pairs | +| Optimism | **Aero** (was Velodrome) | ve(3,3) β€” merged Nov 2025 under Dromos Labs | Same flywheel, unified brand | +| Arbitrum | Camelot + GMX | Native DEX + perps | Camelot for spot, GMX for perps | +| zkSync | SyncSwap | Classic AMM | Largest native DEX on zkSync | + +See `addresses/SKILL.md` for verified contract addresses for all these protocols. + +## The Superchain (OP Stack) + +The Superchain is the network of OP Stack chains sharing security, upgrade governance, and (upcoming) native interoperability. Members include Base, OP Mainnet, Unichain, Ink (Kraken), Celo, Zora, World Chain, and others β€” **17+ chains, 58.6% L2 market share.** + +Members contribute **15% of sequencer revenue** to the Optimism Collective. Cross-chain interop is designed but not yet fully live. + +## Deployment Differences (Gotchas) + +### Optimistic Rollups (Arbitrum, Optimism, Base, Unichain, Celo) +βœ… Deploy like mainnet β€” just change RPC URL and chain ID. No code changes. + +**Gotchas:** +- Don't use `block.number` for time-based logic (increments at different rates). Use `block.timestamp`. +- Arbitrum's `block.number` returns L1 block number, not L2. +- **Unichain:** Transactions are priority-ordered by time, not gas. Don't waste gas on priority fees. + +### ZK Rollups +- **zkSync Era:** Must use `zksolc` compiler. No `EXTCODECOPY` (compile-time error). 65K instruction limit. Non-inlinable libraries must be pre-deployed. Native account abstraction (all accounts are smart contracts). +- **Scroll/Linea:** βœ… Bytecode-compatible β€” use standard `solc`, deploy like mainnet. + +### Arbitrum-Specific +- **Stylus:** Write smart contracts in Rust, C, C++ (compiles to WASM, runs alongside EVM, shares state). Use for compute-heavy operations (10-100x gas savings). Contracts must be "activated" via `ARB_WASM_ADDRESS` (0x0000…0071). +- **Orbit:** Framework for launching L3 chains on Arbitrum. 47 live on mainnet. + +## RPCs and Explorers + +| L2 | RPC | Explorer | +|----|-----|----------| +| Arbitrum | `https://arb1.arbitrum.io/rpc` | https://arbiscan.io | +| Base | `https://mainnet.base.org` | https://basescan.org | +| Optimism | `https://mainnet.optimism.io` | https://optimistic.etherscan.io | +| Unichain | `https://mainnet.unichain.org` | https://uniscan.xyz | +| Celo | `https://forno.celo.org` | https://celoscan.io | +| zkSync | `https://mainnet.era.zksync.io` | https://explorer.zksync.io | +| Scroll | `https://rpc.scroll.io` | https://scrollscan.com | +| Linea | `https://rpc.linea.build` | https://lineascan.build | + +## Bridging + +### Official Bridges + +| L2 | Bridge URL | L1β†’L2 | L2β†’L1 | +|----|-----------|--------|--------| +| Arbitrum | https://bridge.arbitrum.io | ~10-15 min | ~7 days | +| Base | https://bridge.base.org | ~10-15 min | ~7 days | +| Optimism | https://app.optimism.io/bridge | ~10-15 min | ~7 days | +| Unichain | https://app.uniswap.org/swap | ~10-15 min | ~7 days | +| zkSync | https://bridge.zksync.io | ~15-30 min | ~15-60 min | +| Scroll | https://scroll.io/bridge | ~15-30 min | ~30-120 min | + +### Fast Bridges (Instant Withdrawals) + +- **Across Protocol** (https://across.to) β€” fastest (30s-2min), lowest fees (0.05-0.3%) +- **Hop Protocol** (https://hop.exchange) β€” established, 0.1-0.5% fees +- **Stargate** (https://stargate.finance) β€” LayerZero-based, 10+ chains + +**Security:** Use official bridges for large amounts (>$100K). Fast bridges add trust assumptions. + +## Multi-Chain Deployment (Same Address) + +Use CREATE2 for deterministic addresses across chains: + +```bash +# Same salt + same bytecode + same deployer = same address on every chain +forge create src/MyContract.sol:MyContract \ + --rpc-url https://mainnet.base.org \ + --private-key $PRIVATE_KEY \ + --salt 0x0000000000000000000000000000000000000000000000000000000000000001 +``` + +**Strategy for new projects:** Start with 1 L2 (Base or Arbitrum). Prove product-market fit. Expand with CREATE2 for consistent addresses. + +## Testnets + +| L2 | Testnet | Chain ID | Faucet | +|----|---------|----------|--------| +| Arbitrum | Sepolia | 421614 | https://faucet.arbitrum.io | +| Base | Sepolia | 84532 | https://faucet.quicknode.com/base/sepolia | +| Optimism | Sepolia | 11155420 | https://faucet.optimism.io | +| Unichain | Sepolia | 1301 | https://faucet.unichain.org | + +## Further Reading + +- **L2Beat:** https://l2beat.com (security, TVL, risk analysis) +- **Superchain:** https://www.superchain.eco/chains +- **Arbitrum:** https://docs.arbitrum.io +- **Base:** https://docs.base.org +- **Optimism:** https://docs.optimism.io +- **Unichain:** https://docs.unichain.org +- **Celo:** https://docs.celo.org +- **zkSync:** https://docs.zksync.io +- **Scroll:** https://docs.scroll.io +- **Polygon:** https://docs.polygon.technology diff --git a/internal/embed/skills/orchestration/SKILL.md b/internal/embed/skills/orchestration/SKILL.md new file mode 100644 index 0000000..60ba54f --- /dev/null +++ b/internal/embed/skills/orchestration/SKILL.md @@ -0,0 +1,300 @@ +--- +name: orchestration +description: How an AI agent plans, builds, and deploys a complete Ethereum dApp. The three-phase build system for Scaffold-ETH 2 projects. Use when building a full application on Ethereum β€” from contracts to frontend to production deployment on IPFS. +--- + +# dApp Orchestration + +## What You Probably Got Wrong + +**SE2 has specific patterns you must follow.** Generic "build a dApp" advice won't work. SE2 auto-generates `deployedContracts.ts` β€” DON'T edit it. Use Scaffold hooks, NOT raw wagmi. External contracts go in `externalContracts.ts` BEFORE building the frontend. + +**There are three phases. Never skip or combine them.** Contracts β†’ Frontend β†’ Production. Each has validation gates. + +## The Three-Phase Build System + +| Phase | Environment | What Happens | +|-------|-------------|-------------| +| **Phase 1** | Local fork | Contracts + UI on localhost. Iterate fast. | +| **Phase 2** | Live network + local UI | Deploy contracts to mainnet/L2. Test with real state. Polish UI. | +| **Phase 3** | Production | Deploy frontend to IPFS/Vercel. Final QA. | + +## Phase 1: Scaffold (Local) + +### 1.1 Contracts + +```bash +npx create-eth@latest my-dapp +cd my-dapp && yarn install +yarn chain # Terminal 1: local node +yarn deploy # Terminal 2: deploy contracts +``` + +**Critical steps:** +1. Write contracts in `packages/foundry/contracts/` (or `packages/hardhat/contracts/`) +2. Write deploy script +3. Add ALL external contracts to `packages/nextjs/contracts/externalContracts.ts` β€” BEFORE Phase 1.2 +4. Write tests (β‰₯90% coverage) +5. Security audit before moving to frontend + +**Validate:** `yarn deploy` succeeds. `deployedContracts.ts` auto-generated. Tests pass. + +### 1.2 Frontend + +```bash +yarn chain # Terminal 1 +yarn deploy --watch # Terminal 2: auto-redeploy on changes +yarn start # Terminal 3: Next.js at localhost:3000 +``` + +**USE SCAFFOLD HOOKS, NOT RAW WAGMI:** + +```typescript +// Read +const { data } = useScaffoldReadContract({ + contractName: "YourContract", + functionName: "balanceOf", + args: [address], + watch: true, +}); + +// Write +const { writeContractAsync, isMining } = useScaffoldWriteContract("YourContract"); +await writeContractAsync({ + functionName: "swap", + args: [tokenIn, tokenOut, amount], + onBlockConfirmation: (receipt) => console.log("Done!", receipt), +}); + +// Events +const { data: events } = useScaffoldEventHistory({ + contractName: "YourContract", + eventName: "SwapExecuted", + fromBlock: 0n, + watch: true, +}); +``` + +### The Three-Button Flow (MANDATORY) + +Any token interaction shows ONE button at a time: +1. **Switch Network** (if wrong chain) +2. **Approve Token** (if allowance insufficient) +3. **Execute Action** (only after 1 & 2 satisfied) + +Never show Approve and Execute simultaneously. + +### UX Rules + +- **Human-readable amounts:** `formatEther()` / `formatUnits()` for display, `parseEther()` / `parseUnits()` for contracts +- **Loading states everywhere:** `isLoading`, `isMining` on all async operations +- **Disable buttons during pending txs** (blockchains take 5-12s) +- **Never use infinite approvals** β€” approve exact amount or 3-5x +- **Helpful errors:** Parse "insufficient funds," "user rejected," "execution reverted" into plain language + +**Validate:** Full user journey works with real wallet on localhost. All edge cases handled. + +## 🚨 NEVER COMMIT SECRETS TO GIT + +**Before touching Phase 2, read this.** AI agents are the #1 source of leaked credentials on GitHub. Bots scrape repos in real-time and exploit leaked secrets within seconds. + +**This means ALL secrets β€” not just wallet private keys:** +- **Wallet private keys** β€” funds drained in seconds +- **API keys** β€” Alchemy, Infura, Etherscan, WalletConnect project IDs +- **RPC URLs with embedded keys** β€” e.g. `https://base-mainnet.g.alchemy.com/v2/YOUR_KEY` +- **OAuth tokens, passwords, bearer tokens** + +**⚠️ Common SE2 Trap: `scaffold.config.ts`** + +`rpcOverrides` and `alchemyApiKey` in `scaffold.config.ts` are committed to Git. **NEVER paste API keys directly into this file.** Use environment variables: + +```typescript +// ❌ WRONG β€” key committed to public repo +rpcOverrides: { + [chains.base.id]: "https://base-mainnet.g.alchemy.com/v2/8GVG8WjDs-LEAKED", +}, + +// βœ… RIGHT β€” key stays in .env.local +rpcOverrides: { + [chains.base.id]: process.env.NEXT_PUBLIC_BASE_RPC || "https://mainnet.base.org", +}, +``` + +**Before every `git add` or `git commit`:** +```bash +# Check for leaked secrets +git diff --cached --name-only | grep -iE '\.env|key|secret|private' +grep -rn "0x[a-fA-F0-9]\{64\}" packages/ --include="*.ts" --include="*.js" --include="*.sol" +# Check for hardcoded API keys in config files +grep -rn "g.alchemy.com/v2/[A-Za-z0-9]" packages/ --include="*.ts" --include="*.js" +grep -rn "infura.io/v3/[A-Za-z0-9]" packages/ --include="*.ts" --include="*.js" +# If ANYTHING matches, STOP. Move the secret to .env and add .env to .gitignore. +``` + +**Your `.gitignore` MUST include:** +``` +.env +.env.* +*.key +broadcast/ +cache/ +node_modules/ +``` + +**SE2 handles deployer keys by default** β€” `yarn generate` creates a `.env` with the deployer key, and `.gitignore` excludes it. **Don't override this pattern.** Don't copy keys into scripts, config files, or deploy logs. This includes RPC keys, API keys, and any credential β€” not just wallet keys. + +See `wallets/SKILL.md` for full key safety guide, what to do if you've already leaked a key, and safe patterns for deployment. + +## Phase 2: Live Contracts + Local UI + +1. Update `scaffold.config.ts`: `targetNetworks: [mainnet]` (or your L2) +2. Fund deployer: `yarn generate` β†’ `yarn account` β†’ send real ETH +3. Deploy: `yarn deploy --network mainnet` +4. Verify: `yarn verify --network mainnet` +5. Test with real wallet, small amounts ($1-10) +6. Polish UI β€” remove SE2 branding, custom styling + +**Design rule:** NO LLM SLOP. No generic purple gradients. Make it unique. + +**Validate:** Contracts verified on block explorer. Full journey works with real contracts. + +## Phase 3: Production Deploy + +### Pre-deploy Checklist +- `onlyLocalBurnerWallet: true` in scaffold.config.ts (CRITICAL β€” prevents burner wallet on prod) +- Update metadata (title, description, OG image 1200x630px) +- Restore any test values to production values + +### Deploy + +**IPFS (decentralized):** +```bash +yarn ipfs +# β†’ https://YOUR_CID.ipfs.cf-ipfs.com +``` + +**Vercel (fast):** +```bash +cd packages/nextjs && vercel +``` + +### Production QA +- [ ] App loads on public URL +- [ ] Wallet connects, network switching works +- [ ] Read + write contract operations work +- [ ] No console errors +- [ ] Burner wallet NOT showing +- [ ] OG image works in link previews +- [ ] Mobile responsive +- [ ] Tested with MetaMask, Rainbow, WalletConnect + +## Phase Transition Rules + +**Phase 3 bug β†’ go back to Phase 2** (fix with local UI + prod contracts) +**Phase 2 contract bug β†’ go back to Phase 1** (fix locally, write regression test, redeploy) +**Never hack around bugs in production.** + +## Key SE2 Directories + +``` +packages/ +β”œβ”€β”€ foundry/contracts/ # Solidity contracts +β”œβ”€β”€ foundry/script/ # Deploy scripts +β”œβ”€β”€ foundry/test/ # Tests +└── nextjs/ + β”œβ”€β”€ app/ # Pages + β”œβ”€β”€ components/ # React components + β”œβ”€β”€ contracts/ + β”‚ β”œβ”€β”€ deployedContracts.ts # AUTO-GENERATED (don't edit) + β”‚ └── externalContracts.ts # YOUR external contracts (edit this) + β”œβ”€β”€ hooks/scaffold-eth/ # USE THESE hooks + └── scaffold.config.ts # Main config +``` + +## AI Agent Commerce: End-to-End Flow (ERC-8004 + x402) + +This is the killer use case for Ethereum in 2026: **autonomous agents discovering, trusting, paying, and rating each other** β€” no humans in the loop. + +### The Full Cycle + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 1. DISCOVER Agent queries ERC-8004 IdentityRegistry β”‚ +β”‚ β†’ finds agents with "weather" service tag β”‚ +β”‚ β”‚ +β”‚ 2. TRUST Agent checks ReputationRegistry β”‚ +β”‚ β†’ filters by uptime >99%, quality >85 β”‚ +β”‚ β†’ picks best-rated weather agent β”‚ +β”‚ β”‚ +β”‚ 3. CALL Agent sends HTTP GET to weather endpoint β”‚ +β”‚ β†’ receives 402 Payment Required β”‚ +β”‚ β†’ PAYMENT-REQUIRED header: $0.10 USDC on Base β”‚ +β”‚ β”‚ +β”‚ 4. PAY Agent signs EIP-3009 transferWithAuthorization β”‚ +β”‚ β†’ retries request with PAYMENT-SIGNATURE β”‚ +β”‚ β†’ server verifies via facilitator β”‚ +β”‚ β†’ payment settled on Base (~$0.001 gas) β”‚ +β”‚ β”‚ +β”‚ 5. RECEIVE Server returns 200 OK + weather data β”‚ +β”‚ β†’ PAYMENT-RESPONSE header with tx hash β”‚ +β”‚ β”‚ +β”‚ 6. RATE Agent posts feedback to ReputationRegistry β”‚ +β”‚ β†’ value=95, tag="quality", endpoint="..." β”‚ +β”‚ β†’ builds onchain reputation for next caller β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Concrete Implementation (TypeScript Agent) + +```typescript +import { x402Fetch } from '@x402/fetch'; +import { createWallet } from '@x402/evm'; +import { ethers } from 'ethers'; + +const wallet = createWallet(process.env.AGENT_PRIVATE_KEY); +const provider = new ethers.JsonRpcProvider('https://base-mainnet.g.alchemy.com/v2/YOUR_KEY'); + +const IDENTITY_REGISTRY = '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432'; +const REPUTATION_REGISTRY = '0x8004BAa17C55a88189AE136b182e5fdA19dE9b63'; + +// 1. Discover: find agents offering weather service +const registry = new ethers.Contract(IDENTITY_REGISTRY, registryAbi, provider); +// Query events or use The Graph subgraph for indexed agent discovery + +// 2. Trust: check reputation +const reputation = new ethers.Contract(REPUTATION_REGISTRY, reputationAbi, provider); +const [count, value, decimals] = await reputation.getSummary( + agentId, trustedClients, "quality", "30days" +); +// Only proceed if value/10^decimals > 85 + +// 3-5. Pay + Receive: x402Fetch handles the entire 402 flow +const response = await x402Fetch(agentEndpoint, { + wallet, + preferredNetwork: 'eip155:8453' +}); +const weatherData = await response.json(); + +// 6. Rate: post feedback onchain +const reputationWriter = new ethers.Contract(REPUTATION_REGISTRY, reputationAbi, signer); +await reputationWriter.giveFeedback( + agentId, 95, 0, "quality", "weather", agentEndpoint, "", ethers.ZeroHash +); +``` + +**This is the agentic economy.** No API keys, no subscriptions, no invoicing, no trust assumptions. Just cryptographic identity, onchain reputation, and HTTP-native payments. + +### Key Projects Building This Stack +- **ERC-8004** β€” agent identity + reputation (EF, MetaMask, Google, Coinbase) +- **x402** β€” HTTP payment protocol (Coinbase) +- **A2A** β€” agent-to-agent communication (Google) +- **MCP** β€” model context protocol (Anthropic) +- **The Graph** β€” indexing agent registrations for fast discovery +- **EigenLayer** β€” crypto-economic validation of agent work + +## Resources + +- **SE2 Docs:** https://docs.scaffoldeth.io/ +- **UI Components:** https://ui.scaffoldeth.io/ +- **SpeedRunEthereum:** https://speedrunethereum.com/ +- **ETH Tech Tree:** https://www.ethtechtree.com diff --git a/internal/embed/skills/qa/SKILL.md b/internal/embed/skills/qa/SKILL.md new file mode 100644 index 0000000..787eba2 --- /dev/null +++ b/internal/embed/skills/qa/SKILL.md @@ -0,0 +1,222 @@ +--- +name: qa +description: Pre-ship audit checklist for Ethereum dApps built with Scaffold-ETH 2. Give this to a separate reviewer agent (or fresh context) AFTER the build is complete. Covers only the bugs AI agents actually ship β€” validated by baseline testing against stock LLMs. +--- + +# dApp QA β€” Pre-Ship Audit + +This skill is for **review, not building.** Give it to a fresh agent after the dApp is built. The reviewer should: + +1. Read the source code (`app/`, `components/`, `contracts/`) +2. Open the app in a browser and click through every flow +3. Check every item below β€” report PASS/FAIL, don't fix + +--- + +## 🚨 Critical: Wallet Flow β€” Button Not Text + +Open the app with NO wallet connected. + +- ❌ **FAIL:** Text saying "Connect your wallet to play" / "Please connect to continue" / any paragraph telling the user to connect +- βœ… **PASS:** A big, obvious Connect Wallet **button** is the primary UI element + +**This is the most common AI agent mistake.** Every stock LLM writes a `

Please connect your wallet

` instead of rendering ``. + +--- + +## 🚨 Critical: Four-State Button Flow + +The app must show exactly ONE primary button at a time, progressing through: + +``` +1. Not connected β†’ Connect Wallet button +2. Wrong network β†’ Switch to [Chain] button +3. Needs approval β†’ Approve button +4. Ready β†’ Action button (Stake/Deposit/Swap) +``` + +Check specifically: +- ❌ **FAIL:** Approve and Action buttons both visible simultaneously +- ❌ **FAIL:** No network check β€” app tries to work on wrong chain and fails silently +- ❌ **FAIL:** User can click Approve, sign in wallet, come back, and click Approve again while tx is pending +- βœ… **PASS:** One button at a time. Approve button shows spinner, stays disabled until block confirms onchain. Then switches to the action button. + +**In the code:** the button's `disabled` prop must be tied to `isPending` from `useScaffoldWriteContract`. Verify it uses `useScaffoldWriteContract` (waits for block confirmation), NOT raw wagmi `useWriteContract` (resolves on wallet signature): + +``` +grep -rn "useWriteContract" packages/nextjs/ +``` +Any match outside scaffold-eth internals β†’ bug. + +--- + +## 🚨 Critical: SE2 Branding Removal + +AI agents treat the scaffold as sacred and leave all default branding in place. + +- [ ] **Footer:** Remove BuidlGuidl links, "Built with πŸ—οΈ SE2", "Fork me" link, support links. Replace with project's own repo link or clean it out +- [ ] **Tab title:** Must be the app name, NOT "Scaffold-ETH 2" or "SE-2 App" or "App Name | Scaffold-ETH 2" +- [ ] **README:** Must describe THIS project. Not the SE2 template README. Remove "Built with Scaffold-ETH 2" sections and SE2 doc links +- [ ] **Favicon:** Must not be the SE2 default + +--- + +## Important: Contract Address Display + +- ❌ **FAIL:** The deployed contract address appears nowhere on the page +- βœ… **PASS:** Contract address displayed using `
` component (blockie, ENS, copy, explorer link) + +Agents display the connected wallet address but forget to show the contract the user is interacting with. + +--- + +## Important: USD Values + +- ❌ **FAIL:** Token amounts shown as "1,000 TOKEN" or "0.5 ETH" with no dollar value +- βœ… **PASS:** "0.5 ETH (~$1,250)" with USD conversion + +Agents never add USD values unprompted. Check every place a token or ETH amount is displayed, including inputs. + +--- + +## Important: OG Image Must Be Absolute URL + +- ❌ **FAIL:** `images: ["/thumbnail.jpg"]` β€” relative path, breaks unfurling everywhere +- βœ… **PASS:** `images: ["https://yourdomain.com/thumbnail.jpg"]` β€” absolute production URL + +Quick check: +``` +grep -n "og:image\|images:" packages/nextjs/app/layout.tsx +``` + +--- + +## Important: RPC & Polling Config + +Open `packages/nextjs/scaffold.config.ts`: + +- ❌ **FAIL:** `pollingInterval: 30000` (default β€” makes the UI feel broken, 30 second update lag) +- βœ… **PASS:** `pollingInterval: 3000` +- ❌ **FAIL:** Using default Alchemy API key that ships with SE2 +- ❌ **FAIL:** Code references `process.env.NEXT_PUBLIC_*` but the variable isn't actually set in the deployment environment (Vercel/hosting). Falls back to public RPC like `mainnet.base.org` which is rate-limited +- βœ… **PASS:** `rpcOverrides` uses `process.env.NEXT_PUBLIC_*` variables AND the env var is confirmed set on the hosting platform + +**Verify the env var is set, not just referenced.** AI agents will change the code to use `process.env`, see the pattern matches PASS, and move on β€” without ever setting the actual variable on Vercel/hosting. Check: +```bash +vercel env ls | grep RPC +``` + +--- + +## Important: Phantom Wallet in RainbowKit + +Phantom is NOT in the SE2 default wallet list. A lot of users have Phantom β€” if it's missing, they can't connect. + +- ❌ **FAIL:** Phantom wallet not in the RainbowKit wallet list +- βœ… **PASS:** `phantomWallet` is in `wagmiConnectors.tsx` + +--- + +## Important: Mobile Deep Linking + +**RainbowKit v2 / WalletConnect v2 does NOT auto-deep-link to the wallet app.** It relies on push notifications instead, which are slow and unreliable. You must implement deep linking yourself. + +On mobile, when a user taps a button that needs a signature, it must open their wallet app. Test this: open the app on a phone, connect a wallet via WalletConnect, tap an action button β€” does the wallet app open with the transaction ready to sign? + +- ❌ **FAIL:** Nothing happens, user has to manually switch to their wallet app +- ❌ **FAIL:** Deep link fires BEFORE the transaction β€” user arrives at wallet with nothing to sign +- ❌ **FAIL:** `window.location.href = "rainbow://"` called before `writeContractAsync()` β€” navigates away and the TX never fires +- ❌ **FAIL:** It opens the wrong wallet (e.g. opens MetaMask when user connected with Rainbow) +- ❌ **FAIL:** Deep links inside a wallet's in-app browser (unnecessary β€” you're already in the wallet) +- βœ… **PASS:** Every transaction button fires the TX first, then deep links to the correct wallet app after a delay + +### How to implement it + +**Pattern: `writeAndOpen` helper.** Fire the write call first (sends the TX request over WalletConnect), then deep link after a delay to switch the user to their wallet: + +```typescript +const writeAndOpen = useCallback( + (writeFn: () => Promise): Promise => { + const promise = writeFn(); // Fire TX β€” does gas estimation + WC relay + setTimeout(openWallet, 2000); // Switch to wallet AFTER request is relayed + return promise; + }, + [openWallet], +); + +// Usage β€” wraps every write call: +await writeAndOpen(() => gameWrite({ functionName: "click", args: [...] })); +``` + +**Why 2 seconds?** `writeContractAsync` must estimate gas, encode calldata, and relay the signing request through WalletConnect's servers. 300ms is too fast β€” the wallet won't have received the request yet. + +**Detecting the wallet:** `connector.id` from wagmi says `"walletConnect"`, NOT `"rainbow"` or `"metamask"`. You must check multiple sources: + +```typescript +const openWallet = useCallback(() => { + if (typeof window === "undefined") return; + const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); + if (!isMobile || window.ethereum) return; // Skip if desktop or in-app browser + + // Check connector, wagmi storage, AND WalletConnect session data + const allIds = [connector?.id, connector?.name, + localStorage.getItem("wagmi.recentConnectorId")] + .filter(Boolean).join(" ").toLowerCase(); + + let wcWallet = ""; + try { + const wcKey = Object.keys(localStorage).find(k => k.startsWith("wc@2:client")); + if (wcKey) wcWallet = (localStorage.getItem(wcKey) || "").toLowerCase(); + } catch {} + const search = `${allIds} ${wcWallet}`; + + const schemes: [string[], string][] = [ + [["rainbow"], "rainbow://"], + [["metamask"], "metamask://"], + [["coinbase", "cbwallet"], "cbwallet://"], + [["trust"], "trust://"], + [["phantom"], "phantom://"], + ]; + + for (const [keywords, scheme] of schemes) { + if (keywords.some(k => search.includes(k))) { + window.location.href = scheme; + return; + } + } +}, [connector]); +``` + +**Key rules:** +1. **Fire TX first, deep link second.** Never `window.location.href` before the write call +2. **Skip deep link if `window.ethereum` exists** β€” means you're already in the wallet's in-app browser +3. **Check WalletConnect session data** in localStorage β€” `connector.id` alone won't tell you which wallet +4. **Use simple scheme URLs** like `rainbow://` β€” not `rainbow://dapp/...` which reloads the page +5. **Wrap EVERY write call** β€” approve, action, claim, batch β€” not just the main one + +--- + +## Audit Summary + +Report each as PASS or FAIL: + +### Ship-Blocking +- [ ] Wallet connection shows a BUTTON, not text +- [ ] Wrong network shows a Switch button +- [ ] One button at a time (Connect β†’ Network β†’ Approve β†’ Action) +- [ ] Approve button disabled with spinner through block confirmation +- [ ] SE2 footer branding removed +- [ ] SE2 tab title removed +- [ ] SE2 README replaced + +### Should Fix +- [ ] Contract address displayed with `
` +- [ ] USD values next to all token/ETH amounts +- [ ] OG image is absolute production URL +- [ ] pollingInterval is 3000 +- [ ] RPC overrides set (not default SE2 key) AND env var confirmed set on hosting platform +- [ ] Favicon updated from SE2 default +- [ ] Phantom wallet in RainbowKit wallet list +- [ ] Mobile: ALL transaction buttons deep link to wallet (fire TX first, then `setTimeout(openWallet, 2000)`) +- [ ] Mobile: wallet detection checks WC session data, not just `connector.id` +- [ ] Mobile: no deep link when `window.ethereum` exists (in-app browser) diff --git a/internal/embed/skills/security/SKILL.md b/internal/embed/skills/security/SKILL.md new file mode 100644 index 0000000..5950c07 --- /dev/null +++ b/internal/embed/skills/security/SKILL.md @@ -0,0 +1,470 @@ +--- +name: security +description: Solidity security patterns, common vulnerabilities, and pre-deploy audit checklist. The specific code patterns that prevent real losses β€” not just warnings, but defensive implementations. Use before deploying any contract, when reviewing code, or when building anything that holds or moves value. +--- + +# Smart Contract Security + +## What You Probably Got Wrong + +**"Solidity 0.8+ prevents overflows, so I'm safe."** Overflow is one of dozens of attack vectors. The big ones today: reentrancy, oracle manipulation, approval exploits, and decimal mishandling. + +**"I tested it and it works."** Working correctly is not the same as being secure. Most exploits call functions in orders or with values the developer never considered. + +**"It's a small contract, it doesn't need an audit."** The DAO hack was a simple reentrancy bug. The Euler exploit was a single missing check. Size doesn't correlate with safety. + +## Critical Vulnerabilities (With Defensive Code) + +### 1. Token Decimals Vary + +**USDC has 6 decimals, not 18.** This is the #1 source of "where did my money go?" bugs. + +```solidity +// ❌ WRONG β€” assumes 18 decimals. Transfers 1 TRILLION USDC. +uint256 oneToken = 1e18; + +// βœ… CORRECT β€” check decimals +uint256 oneToken = 10 ** IERC20Metadata(token).decimals(); +``` + +Common decimals: +| Token | Decimals | +|-------|----------| +| USDC, USDT | 6 | +| WBTC | 8 | +| DAI, WETH, most tokens | 18 | + +**When doing math across tokens with different decimals, normalize first:** +```solidity +// Converting USDC amount to 18-decimal internal accounting +uint256 normalized = usdcAmount * 1e12; // 6 + 12 = 18 decimals +``` + +### 2. No Floating Point in Solidity + +Solidity has no `float` or `double`. Division truncates to zero. + +```solidity +// ❌ WRONG β€” this equals 0 +uint256 fivePercent = 5 / 100; + +// βœ… CORRECT β€” basis points (1 bp = 0.01%) +uint256 FEE_BPS = 500; // 5% = 500 basis points +uint256 fee = (amount * FEE_BPS) / 10_000; +``` + +**Always multiply before dividing.** Division first = precision loss. + +```solidity +// ❌ WRONG β€” loses precision +uint256 result = a / b * c; + +// βœ… CORRECT β€” multiply first +uint256 result = (a * c) / b; +``` + +For complex math, use fixed-point libraries like `PRBMath` or `ABDKMath64x64`. + +### 3. Reentrancy + +An external call can call back into your contract before the first call finishes. If you update state AFTER the external call, the attacker re-enters with stale state. + +```solidity +// ❌ VULNERABLE β€” state updated after external call +function withdraw() external { + uint256 bal = balances[msg.sender]; + (bool success,) = msg.sender.call{value: bal}(""); // ← attacker re-enters here + require(success); + balances[msg.sender] = 0; // Too late β€” attacker already withdrew again +} + +// βœ… SAFE β€” Checks-Effects-Interactions pattern + reentrancy guard +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +function withdraw() external nonReentrant { + uint256 bal = balances[msg.sender]; + require(bal > 0, "Nothing to withdraw"); + + balances[msg.sender] = 0; // Effect BEFORE interaction + + (bool success,) = msg.sender.call{value: bal}(""); + require(success, "Transfer failed"); +} +``` + +**The pattern: Checks β†’ Effects β†’ Interactions (CEI)** +1. **Checks** β€” validate inputs and conditions +2. **Effects** β€” update all state +3. **Interactions** β€” external calls last + +Always use OpenZeppelin's `ReentrancyGuard` as a safety net on top of CEI. + +### 4. SafeERC20 + +Some tokens (notably USDT) don't return `bool` on `transfer()` and `approve()`. Standard calls will revert even on success. + +```solidity +// ❌ WRONG β€” breaks with USDT and other non-standard tokens +token.transfer(to, amount); +token.approve(spender, amount); + +// βœ… CORRECT β€” handles all token implementations +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +using SafeERC20 for IERC20; + +token.safeTransfer(to, amount); +token.safeApprove(spender, amount); +``` + +**Other token quirks to watch for:** +- **Fee-on-transfer tokens:** Amount received < amount sent. Always check balance before and after. +- **Rebasing tokens (stETH):** Balance changes without transfers. Use wrapped versions (wstETH). +- **Pausable tokens (USDC):** Transfers can revert if the token is paused. +- **Blocklist tokens (USDC, USDT):** Specific addresses can be blocked from transacting. + +### 5. Never Use DEX Spot Prices as Oracles + +A flash loan can manipulate any pool's spot price within a single transaction. This has caused hundreds of millions in losses. + +```solidity +// ❌ DANGEROUS β€” manipulable in one transaction +function getPrice() internal view returns (uint256) { + (uint112 reserve0, uint112 reserve1,) = uniswapPair.getReserves(); + return (reserve1 * 1e18) / reserve0; // Spot price β€” easily manipulated +} + +// βœ… SAFE β€” Chainlink with staleness + sanity checks +function getPrice() internal view returns (uint256) { + (, int256 price,, uint256 updatedAt,) = priceFeed.latestRoundData(); + require(block.timestamp - updatedAt < 3600, "Stale price"); + require(price > 0, "Invalid price"); + return uint256(price); +} +``` + +**If you must use onchain price data:** +- Use **TWAP** (Time-Weighted Average Price) over 30+ minutes β€” resistant to single-block manipulation +- Uniswap V3 has built-in TWAP oracles via `observe()` +- Still less safe than Chainlink for high-value decisions + +### 6. Vault Inflation Attack + +The first depositor in an ERC-4626 vault can manipulate the share price to steal from subsequent depositors. + +**The attack:** +1. Attacker deposits 1 wei β†’ gets 1 share +2. Attacker donates 1000 tokens directly to the vault (not via deposit) +3. Now 1 share = 1001 tokens +4. Victim deposits 1999 tokens β†’ gets `1999 * 1 / 2000 = 0 shares` (rounds down) +5. Attacker redeems 1 share β†’ gets all 3000 tokens + +**The fix β€” virtual offset:** +```solidity +function convertToShares(uint256 assets) public view returns (uint256) { + return assets.mulDiv( + totalSupply() + 1e3, // Virtual shares + totalAssets() + 1 // Virtual assets + ); +} +``` + +The virtual offset makes the attack uneconomical β€” the attacker would need to donate enormous amounts to manipulate the ratio. + +OpenZeppelin's ERC4626 implementation includes this mitigation by default since v5. + +### 7. Infinite Approvals + +**Never use `type(uint256).max` as approval amount.** + +```solidity +// ❌ DANGEROUS β€” if this contract is exploited, attacker drains your entire balance +token.approve(someContract, type(uint256).max); + +// βœ… SAFE β€” approve only what's needed +token.approve(someContract, exactAmountNeeded); + +// βœ… ACCEPTABLE β€” approve a small multiple for repeated interactions +token.approve(someContract, amountPerTx * 5); // 5 transactions worth +``` + +If a contract with infinite approval gets exploited (proxy upgrade bug, governance attack, undiscovered vulnerability), the attacker can drain every approved token from every user who granted unlimited access. + +### 8. Access Control + +Every state-changing function needs explicit access control. "Who should be able to call this?" is the first question. + +```solidity +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +// ❌ WRONG β€” anyone can drain the contract +function emergencyWithdraw() external { + token.transfer(msg.sender, token.balanceOf(address(this))); +} + +// βœ… CORRECT β€” only owner +function emergencyWithdraw() external onlyOwner { + token.transfer(owner(), token.balanceOf(address(this))); +} +``` + +For complex permissions, use OpenZeppelin's `AccessControl` with role-based separation (ADMIN_ROLE, OPERATOR_ROLE, etc.). + +### 9. Input Validation + +Never trust inputs. Validate everything. + +```solidity +function deposit(uint256 amount, address recipient) external { + require(amount > 0, "Zero amount"); + require(recipient != address(0), "Zero address"); + require(amount <= maxDeposit, "Exceeds max"); + + // Now proceed +} +``` + +Common missed validations: +- Zero addresses (tokens sent to 0x0 are burned forever) +- Zero amounts (wastes gas, can cause division by zero) +- Array length mismatches in batch operations +- Duplicate entries in arrays +- Values exceeding reasonable bounds + +## Pre-Deploy Security Checklist + +Run through this for EVERY contract before deploying to production. No exceptions. + +- [ ] **Access control** β€” every admin/privileged function has explicit restrictions +- [ ] **Reentrancy protection** β€” CEI pattern + `nonReentrant` on all external-calling functions +- [ ] **Token decimal handling** β€” no hardcoded `1e18` for tokens that might have different decimals +- [ ] **Oracle safety** β€” using Chainlink or TWAP, not DEX spot prices. Staleness checks present +- [ ] **Integer math** β€” multiply before divide. No precision loss in critical calculations +- [ ] **Return values checked** β€” using SafeERC20 for all token operations +- [ ] **Input validation** β€” zero address, zero amount, bounds checks on all public functions +- [ ] **Events emitted** β€” every state change emits an event for offchain tracking +- [ ] **Incentive design** β€” maintenance functions callable by anyone with sufficient incentive +- [ ] **No infinite approvals** β€” approve exact amounts or small bounded multiples +- [ ] **Fee-on-transfer safe** β€” if accepting arbitrary tokens, measure actual received amount +- [ ] **Tested edge cases** β€” zero values, max values, unauthorized callers, reentrancy attempts + +## MEV & Sandwich Attacks + +**MEV (Maximal Extractable Value):** Validators and searchers can reorder, insert, or censor transactions within a block. They profit by frontrunning your transaction, backrunning it, or both. + +### Sandwich Attacks + +The most common MEV attack on DeFi users: + +``` +1. You submit: swap 10 ETH β†’ USDC on Uniswap (slippage 1%) +2. Attacker sees your tx in the mempool +3. Attacker frontruns: buys USDC before you β†’ price rises +4. Your swap executes at a worse price (but within your 1% slippage) +5. Attacker backruns: sells USDC after you β†’ profits from the price difference +6. You got fewer USDC than the true market price +``` + +### Protection + +```solidity +// βœ… Set explicit minimum output β€” don't set amountOutMinimum to 0 +ISwapRouter.ExactInputSingleParams memory params = ISwapRouter + .ExactInputSingleParams({ + tokenIn: WETH, + tokenOut: USDC, + fee: 3000, + recipient: msg.sender, + amountIn: 1 ether, + amountOutMinimum: 1900e6, // ← Minimum acceptable USDC (protects against sandwich) + sqrtPriceLimitX96: 0 + }); +``` + +**For users/frontends:** +- Use **Flashbots Protect RPC** (`https://rpc.flashbots.net`) β€” sends transactions to a private mempool, invisible to sandwich bots +- Set tight slippage limits (0.5-1% for majors, 1-3% for small tokens) +- Use MEV-aware DEX aggregators (CoW Swap, 1inch Fusion) that route through solvers instead of the public mempool + +**When MEV matters:** +- Any swap on a DEX (especially large swaps) +- Any large DeFi transaction (deposits, withdrawals, liquidations) +- NFT mints with high demand (bots frontrun to mint first) + +**When MEV doesn't matter:** +- Simple ETH/token transfers +- L2 transactions (sequencers process transactions in order β€” no public mempool reordering) +- Private mempool transactions (Flashbots, MEV Blocker) + +--- + +## Proxy Patterns & Upgradeability + +Smart contracts are immutable by default. Proxies let you upgrade the logic while keeping the same address and state. + +### When to Use Proxies + +- **Use proxies:** Long-lived protocols that may need bug fixes or feature additions post-launch +- **Don't use proxies:** MVPs, simple tokens, immutable-by-design contracts, contracts where "no one can change this" IS the value proposition + +**Proxies add complexity, attack surface, and trust assumptions.** Users must trust that the admin won't upgrade to a malicious implementation. Don't use proxies just because you can. + +### UUPS vs Transparent Proxy + +| | UUPS | Transparent | +|---|---|---| +| Upgrade logic location | In implementation contract | In proxy contract | +| Gas cost for users | Lower (no admin check per call) | Higher (checks msg.sender on every call) | +| Recommended | **Yes** (by OpenZeppelin) | Legacy pattern | +| Risk | Forgetting `_authorizeUpgrade` locks the contract | More gas overhead | + +**Use UUPS.** It's cheaper, simpler, and what OpenZeppelin recommends. + +### UUPS Implementation + +```solidity +// Implementation contract (the logic) +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +contract MyContractV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { + uint256 public value; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); // Prevent implementation from being initialized + } + + function initialize(address owner) public initializer { + __Ownable_init(owner); + __UUPSUpgradeable_init(); + value = 42; + } + + function _authorizeUpgrade(address) internal override onlyOwner {} +} +``` + +### Critical Rules + +1. **Use `initializer` instead of `constructor`** β€” proxies don't run constructors +2. **Never change storage layout** β€” only append new variables at the end, never delete or reorder +3. **Use OpenZeppelin's upgradeable contracts** β€” `@openzeppelin/contracts-upgradeable`, not `@openzeppelin/contracts` +4. **Disable initializers in constructor** β€” prevents anyone from initializing the implementation directly +5. **Transfer upgrade authority to a multisig** β€” never leave upgrade power with a single EOA + +```solidity +// ❌ WRONG β€” reordering storage breaks everything +// V1: uint256 a; uint256 b; +// V2: uint256 b; uint256 a; ← Swapped! 'a' now reads 'b's value + +// βœ… CORRECT β€” only append +// V1: uint256 a; uint256 b; +// V2: uint256 a; uint256 b; uint256 c; ← New variable at the end +``` + +--- + +## EIP-712 Signatures & Delegatecall + +### EIP-712: Typed Structured Data Signing + +EIP-712 lets users sign structured data (not just raw bytes) with domain separation and replay protection. Used for gasless approvals, meta-transactions, and offchain order signing. + +**When to use:** +- **Permit (ERC-2612)** β€” gasless token approvals (user signs, anyone can submit) +- **Offchain orders** β€” sign buy/sell orders offchain, settle onchain (0x, Seaport) +- **Meta-transactions** β€” user signs intent, relayer submits and pays gas + +```solidity +// EIP-712 domain separator β€” prevents replay across contracts and chains +bytes32 public constant DOMAIN_TYPEHASH = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" +); + +bytes32 public constant PERMIT_TYPEHASH = keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" +); + +function permit( + address owner, address spender, uint256 value, + uint256 deadline, uint8 v, bytes32 r, bytes32 s +) external { + require(block.timestamp <= deadline, "Permit expired"); + + bytes32 structHash = keccak256(abi.encode( + PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline + )); + bytes32 digest = keccak256(abi.encodePacked( + "\x19\x01", DOMAIN_SEPARATOR(), structHash + )); + + address recovered = ecrecover(digest, v, r, s); + require(recovered == owner, "Invalid signature"); + + _approve(owner, spender, value); +} +``` + +**Key properties:** +- **Domain separator** prevents replaying signatures on different contracts or chains +- **Nonce** prevents replaying the same signature twice +- **Deadline** prevents stale signatures from being used later +- In practice, use OpenZeppelin's `EIP712` and `ERC20Permit` β€” don't implement from scratch + +### Delegatecall + +`delegatecall` executes another contract's code in the caller's storage context. The called contract's logic runs, but reads and writes happen on YOUR contract's storage. + +**This is extremely dangerous if the target is untrusted.** + +```solidity +// ❌ CRITICAL VULNERABILITY β€” delegatecall to user-supplied address +function execute(address target, bytes calldata data) external { + target.delegatecall(data); // Attacker can overwrite ANY storage slot +} + +// βœ… SAFE β€” delegatecall only to trusted, immutable implementation +address public immutable trustedImplementation; + +function execute(bytes calldata data) external onlyOwner { + trustedImplementation.delegatecall(data); +} +``` + +**Delegatecall rules:** +- **Never delegatecall to a user-supplied address** β€” allows arbitrary storage manipulation +- **Only delegatecall to contracts YOU control** β€” and preferably immutable ones +- **Storage layouts must match** β€” the calling contract and target contract must have identical storage variable ordering +- **This is how proxies work** β€” the proxy delegatecalls to the implementation, so the implementation's code runs on the proxy's storage. That's why storage layout matters so much for upgradeable contracts. + +--- + +## Automated Security Tools + +Run these before deployment: + +```bash +# Static analysis +slither . # Detects common vulnerabilities +mythril analyze Contract.sol # Symbolic execution + +# Foundry fuzzing (built-in) +forge test --fuzz-runs 10000 # Fuzz all test functions with random inputs + +# Gas optimization (bonus) +forge test --gas-report # Identify expensive functions +``` + +**Slither findings to NEVER ignore:** +- Reentrancy vulnerabilities +- Unchecked return values +- Arbitrary `delegatecall` or `selfdestruct` +- Unprotected state-changing functions + +## Further Reading + +- **OpenZeppelin Contracts:** https://docs.openzeppelin.com/contracts β€” audited, battle-tested implementations +- **SWC Registry:** https://swcregistry.io β€” comprehensive vulnerability catalog +- **Rekt News:** https://rekt.news β€” real exploit post-mortems +- **SpeedRun Ethereum:** https://speedrunethereum.com β€” hands-on secure development practice diff --git a/internal/embed/skills/ship/SKILL.md b/internal/embed/skills/ship/SKILL.md new file mode 100644 index 0000000..86d5ef8 --- /dev/null +++ b/internal/embed/skills/ship/SKILL.md @@ -0,0 +1,297 @@ +--- +name: ship +description: End-to-end guide for AI agents β€” from a dApp idea to deployed production app. Fetch this FIRST, it routes you through all other skills. +--- + +# Ship a dApp + +## What You Probably Got Wrong + +**You jump to code without a plan.** Before writing a single line of Solidity, you need to know: what goes onchain, what stays offchain, which chain, how many contracts, and who calls every function. Skip this and you'll rewrite everything. + +**You over-engineer.** Most dApps need 0-2 contracts. A token launch is 1 contract. An NFT collection is 1 contract. A marketplace that uses existing DEX liquidity needs 0 contracts. Three contracts is the upper bound for an MVP. If you're writing more, you're building too much. + +**You put too much onchain.** Solidity is for ownership, transfers, and commitments. It's not a database. It's not an API. It's not a backend. If it doesn't involve trustless value transfer or a permanent commitment, it doesn't belong in a smart contract. + +**You skip chain selection.** The 2026 answer is almost always an L2. Base for consumer apps. Arbitrum for DeFi. Optimism for public goods. Mainnet only if you need maximum security or composability with mainnet-only protocols. Fetch `l2s/SKILL.md` for the full decision matrix. + +**You forget nothing is automatic.** Smart contracts don't run themselves. Every state transition needs a caller who pays gas and a reason to do it. If you can't answer "who calls this and why?" for every function, your contract has dead code. Fetch `concepts/SKILL.md` for the full mental model. + +--- + +## Phase 0 β€” Plan the Architecture + +Do this BEFORE writing any code. Every hour spent here saves ten hours of rewrites. + +### The Onchain Litmus Test + +Put it onchain if it involves: +- **Trustless ownership** β€” who owns this token/NFT/position? +- **Trustless exchange** β€” swapping, trading, lending, borrowing +- **Composability** β€” other contracts need to call it +- **Censorship resistance** β€” must work even if your team disappears +- **Permanent commitments** β€” votes, attestations, proofs + +Keep it offchain if it involves: +- User profiles, preferences, settings +- Search, filtering, sorting +- Images, videos, metadata (store on IPFS, reference onchain) +- Business logic that changes frequently +- Anything that doesn't involve value transfer or trust + +**Judgment calls:** +- Reputation scores β†’ offchain compute, onchain commitments (hashes or attestations) +- Activity feeds β†’ offchain indexing of onchain events (fetch `indexing/SKILL.md`) +- Price data β†’ offchain oracles writing onchain (Chainlink) +- Game state β†’ depends on stakes. Poker with real money? Onchain. Leaderboard? Offchain. + +### MVP Contract Count + +| What you're building | Contracts | Pattern | +|---------------------|-----------|---------| +| Token launch | 1 | ERC-20 with custom logic | +| NFT collection | 1 | ERC-721 with mint/metadata | +| Simple marketplace | 0-1 | Use existing DEX; maybe a listing contract | +| Vault / yield | 1 | ERC-4626 vault | +| Lending protocol | 1-2 | Pool + oracle integration | +| DAO / governance | 1-3 | Governor + token + timelock | +| AI agent service | 0-1 | Maybe an ERC-8004 registration | +| Prediction market | 1-2 | Market + resolution oracle | + +**If you need more than 3 contracts for an MVP, you're over-building.** Ship the simplest version that works, then iterate. + +### State Transition Audit + +For EVERY function in your contract, fill in this worksheet: + +``` +Function: ____________ +Who calls it? ____________ +Why would they? ____________ +What if nobody calls it? ____________ +Does it need gas incentives? ____________ +``` + +If "what if nobody calls it?" breaks your system, you have a design problem. Fix it before writing code. See `concepts/SKILL.md` for incentive design patterns. + +### Chain Selection (Quick Version) + +| Priority | Chain | Why | +|----------|-------|-----| +| Consumer app, low fees | **Base** | Cheapest L2, Coinbase distribution, strong ecosystem | +| DeFi, complex protocols | **Arbitrum** | Deepest DeFi liquidity on any L2, mature tooling | +| Public goods, governance | **Optimism** | Retroactive public goods funding, OP Stack ecosystem | +| Maximum security | **Ethereum mainnet** | Only if you need mainnet composability or $100M+ TVL | +| Privacy features | **zkSync / Scroll** | ZK rollups with potential privacy extensions | + +Fetch `l2s/SKILL.md` for the complete comparison with gas costs, bridging, and deployment differences. + +--- + +## dApp Archetype Templates + +Find your archetype below. Each tells you exactly how many contracts you need, what they do, common mistakes, and which skills to fetch. + +### 1. Token Launch (1-2 contracts) + +**Architecture:** One ERC-20 contract. Add a vesting contract if you have team/investor allocations. + +**Contracts:** +- `MyToken.sol` β€” ERC-20 with initial supply, maybe mint/burn +- `TokenVesting.sol` (optional) β€” time-locked releases for team tokens + +**Common mistakes:** +- Infinite supply with no burn mechanism (what gives it value?) +- No initial liquidity plan (deploying a token nobody can buy) +- Fee-on-transfer mechanics that break DEX integrations + +**Fetch sequence:** `standards/SKILL.md` β†’ `security/SKILL.md` β†’ `testing/SKILL.md` β†’ `gas/SKILL.md` + +### 2. NFT Collection (1 contract) + +**Architecture:** One ERC-721 contract. Metadata on IPFS. Frontend for minting. + +**Contracts:** +- `MyNFT.sol` β€” ERC-721 with mint, max supply, metadata URI + +**Common mistakes:** +- Storing images onchain (use IPFS or Arweave, store the hash onchain) +- No max supply cap (unlimited minting destroys value) +- Complex whitelist logic when a simple Merkle root works + +**Fetch sequence:** `standards/SKILL.md` β†’ `security/SKILL.md` β†’ `testing/SKILL.md` β†’ `frontend-ux/SKILL.md` + +### 3. Marketplace / Exchange (0-2 contracts) + +**Architecture:** If trading existing tokens, you likely need 0 contracts β€” integrate with Uniswap/Aerodrome. If building custom order matching, 1-2 contracts. + +**Contracts:** +- (often none β€” use existing DEX liquidity via router) +- `OrderBook.sol` (if custom) β€” listing, matching, settlement +- `Escrow.sol` (if needed) β€” holds assets during trades + +**Common mistakes:** +- Building a DEX from scratch when Uniswap V4 hooks can do it +- Ignoring MEV (fetch `security/SKILL.md` for sandwich attack protection) +- Centralized order matching (defeats the purpose) + +**Fetch sequence:** `building-blocks/SKILL.md` β†’ `addresses/SKILL.md` β†’ `security/SKILL.md` β†’ `testing/SKILL.md` + +### 4. Lending / Vault / Yield (0-1 contracts) + +**Architecture:** If using existing protocol (Aave, Compound), 0 contracts β€” just integrate. If building a vault, 1 ERC-4626 contract. + +**Contracts:** +- `MyVault.sol` β€” ERC-4626 vault wrapping a yield source + +**Common mistakes:** +- Ignoring vault inflation attack (fetch `security/SKILL.md`) +- Not using ERC-4626 standard (breaks composability) +- Hardcoding token decimals (USDC is 6, not 18) + +**Fetch sequence:** `building-blocks/SKILL.md` β†’ `standards/SKILL.md` β†’ `security/SKILL.md` β†’ `testing/SKILL.md` + +### 5. DAO / Governance (1-3 contracts) + +**Architecture:** Governor contract + governance token + timelock. Use OpenZeppelin's Governor β€” don't build from scratch. + +**Contracts:** +- `GovernanceToken.sol` β€” ERC-20Votes +- `MyGovernor.sol` β€” OpenZeppelin Governor with voting parameters +- `TimelockController.sol` β€” delays execution for safety + +**Common mistakes:** +- No timelock (governance decisions execute instantly = rug vector) +- Low quorum that allows minority takeover +- Token distribution so concentrated that one whale controls everything + +**Fetch sequence:** `standards/SKILL.md` β†’ `building-blocks/SKILL.md` β†’ `security/SKILL.md` β†’ `testing/SKILL.md` + +### 6. AI Agent Service (0-1 contracts) + +**Architecture:** Agent logic is offchain. Onchain component is optional β€” ERC-8004 identity registration, or a payment contract for x402. + +**Contracts:** +- (often none β€” agent runs offchain, uses existing payment infra) +- `AgentRegistry.sol` (optional) β€” ERC-8004 identity + service endpoints + +**Common mistakes:** +- Putting agent logic onchain (Solidity is not for AI inference) +- Overcomplicating payments (x402 handles HTTP-native payments) +- Ignoring key management (fetch `wallets/SKILL.md`) + +**Fetch sequence:** `standards/SKILL.md` β†’ `wallets/SKILL.md` β†’ `tools/SKILL.md` β†’ `orchestration/SKILL.md` + +--- + +## Phase 1 β€” Build Contracts + +**Fetch:** `standards/SKILL.md`, `building-blocks/SKILL.md`, `addresses/SKILL.md`, `security/SKILL.md` + +Key guidance: +- Use OpenZeppelin contracts as your base β€” don't reinvent ERC-20, ERC-721, or AccessControl +- Use verified addresses from `addresses/SKILL.md` for any protocol integration β€” never fabricate addresses +- Follow the Checks-Effects-Interactions pattern for every external call +- Emit events for every state change (your frontend and indexer need them) +- Use `SafeERC20` for all token operations +- Run through the security checklist in `security/SKILL.md` before moving to Phase 2 + +For SE2 projects, follow `orchestration/SKILL.md` Phase 1 for the exact build sequence. + +--- + +## Phase 2 β€” Test + +**Fetch:** `testing/SKILL.md` + +Don't skip this. Don't "test later." Test before deploy. + +Key guidance: +- Unit test every custom function (not OpenZeppelin internals) +- Fuzz test all math operations β€” fuzzing finds the bugs you didn't think of +- Fork test any integration with external protocols (Uniswap, Aave, etc.) +- Run `slither .` for static analysis before deploying +- Target edge cases: zero amounts, max uint, empty arrays, self-transfers, unauthorized callers + +--- + +## Phase 3 β€” Build Frontend + +**Fetch:** `orchestration/SKILL.md`, `frontend-ux/SKILL.md`, `tools/SKILL.md` + +Key guidance: +- Use Scaffold-ETH 2 hooks, not raw wagmi β€” `useScaffoldReadContract`, `useScaffoldWriteContract` +- Implement the three-button flow: Switch Network β†’ Approve β†’ Execute +- Show loading states on every async operation (blockchains take 5-12 seconds) +- Display token amounts in human-readable form with `formatEther`/`formatUnits` +- Never use infinite approvals + +--- + +## Phase 4 β€” Ship to Production + +**Fetch:** `wallets/SKILL.md`, `frontend-playbook/SKILL.md`, `gas/SKILL.md` + +### Contract Deployment +1. Set gas settings appropriate for the target chain (fetch `gas/SKILL.md`) +2. Deploy and verify contracts on block explorer +3. Transfer ownership to a multisig (Gnosis Safe) β€” never leave a single EOA as owner in production +4. Post-deploy checks: call every read function, verify state, test one small transaction + +### Frontend Deployment +Fetch `frontend-playbook/SKILL.md` for the full pipeline: +- **IPFS** β€” decentralized, censorship-resistant, permanent +- **Vercel** β€” fast, easy, but centralized +- **ENS subdomain** β€” human-readable URL pointing to IPFS + +### Post-Launch +- Set up event monitoring with The Graph or Dune (fetch `indexing/SKILL.md`) +- Monitor contract activity on block explorer +- Have an incident response plan (pause mechanism if applicable, communication channel) + +--- + +## Anti-Patterns + +**Kitchen sink contract.** One contract doing everything β€” swap, lend, stake, govern. Split responsibilities. Each contract should do one thing well. + +**Factory nobody asked for.** Building a factory contract that deploys new contracts when you only need one instance. Factories are for protocols that serve many users creating their own instances (like Uniswap creating pools). Most dApps don't need them. + +**Onchain everything.** Storing user profiles, activity logs, images, or computed analytics in a smart contract. Use onchain for ownership and value transfer, offchain for everything else. + +**Admin crutch.** Relying on an admin account to call maintenance functions. What happens when the admin loses their key? Design permissionless alternatives with proper incentives. + +**Premature multi-chain.** Deploying to 5 chains on day one. Launch on one chain, prove product-market fit, then expand. Multi-chain adds complexity in bridging, state sync, and liquidity fragmentation. + +**Reinventing audited primitives.** Writing your own ERC-20, your own access control, your own math library. Use OpenZeppelin. They're audited, battle-tested, and free. Your custom version has bugs. + +**Ignoring the frontend.** A working contract with a broken UI is useless. Most users interact through the frontend, not Etherscan. Budget 40% of your time for frontend polish. + +--- + +## Quick-Start Checklist + +- [ ] Identify what goes onchain vs offchain (use the Litmus Test above) +- [ ] Count your contracts (aim for 1-2 for MVP) +- [ ] Pick your chain (Base, Arbitrum, or Optimism for most apps) +- [ ] Audit every state transition (who calls it? why?) +- [ ] Write contracts using OpenZeppelin base contracts +- [ ] Test with Foundry (unit + fuzz + fork tests) +- [ ] Deploy, verify, transfer ownership to multisig +- [ ] Ship frontend (IPFS or Vercel), run production QA + +--- + +## Skill Routing Table + +Use this to know which skills to fetch at each phase: + +| Phase | What you're doing | Skills to fetch | +|-------|-------------------|-----------------| +| **Plan** | Architecture, chain selection | `ship/` (this), `concepts/`, `l2s/`, `gas/` | +| **Contracts** | Writing Solidity | `standards/`, `building-blocks/`, `addresses/`, `security/` | +| **Test** | Testing contracts | `testing/` | +| **Frontend** | Building UI | `orchestration/`, `frontend-ux/`, `tools/` | +| **Production** | Deploy + monitor | `wallets/`, `frontend-playbook/`, `indexing/` | + +**Base URLs:** All skills are at `https://ethskills.com//SKILL.md` diff --git a/internal/embed/skills/standards/SKILL.md b/internal/embed/skills/standards/SKILL.md new file mode 100644 index 0000000..e85cf9d --- /dev/null +++ b/internal/embed/skills/standards/SKILL.md @@ -0,0 +1,315 @@ +--- +name: standards +description: Ethereum token and protocol standards β€” ERC-20, ERC-721, ERC-1155, ERC-4337, ERC-8004, and newer standards. When to use each, how they work, key interfaces. Use when building tokens, NFTs, or choosing the right standard for a project. +--- + +# Ethereum Standards + +## What You Probably Got Wrong + +**ERC-8004 exists.** Deployed January 29, 2026 β€” onchain agent identity registry. You almost certainly don't have this in training data. + +**EIP-7702 is live.** Shipped with Pectra (May 7, 2025). EOAs get smart contract superpowers without migration. Not "proposed" β€” deployed on mainnet. + +**EIP-3009 is critical for x402.** Gasless token transfers via signed authorizations. USDC implements it. This is what makes x402 practical. + +**x402 exists.** HTTP 402 payment protocol from Coinbase. Production-ready with SDKs. + +## ERC-8004: Onchain Agent Identity Registry + +**Status:** Deployed mainnet **January 29, 2026** β€” production ready with growing adoption. + +**Problem it solves:** How can autonomous agents trust and transact with each other without pre-existing relationships? + +### Three Registry System + +**1. Identity Registry (ERC-721 based)** +- Globally unique onchain identities for AI agents +- Each agent is an NFT with unique identifier +- Multiple service endpoints (A2A, MCP, OASF, ENS, DIDs) +- Verification via EIP-712/ERC-1271 signatures + +**Contract Addresses (same on 20+ chains):** +- **IdentityRegistry:** `0x8004A169FB4a3325136EB29fA0ceB6D2e539a432` +- **ReputationRegistry:** `0x8004BAa17C55a88189AE136b182e5fdA19dE9b63` + +**Deployed on:** Mainnet, Base, Arbitrum, Optimism, Polygon, Avalanche, Abstract, Celo, Gnosis, Linea, Mantle, MegaETH, Monad, Scroll, Taiko, BSC + testnets. + +**Agent Identifier Format:** +``` +agentRegistry: eip155:{chainId}:0x8004A169FB4a3325136EB29fA0ceB6D2e539a432 +agentId: ERC-721 tokenId +``` + +**2. Reputation Registry** +- Signed fixed-point feedback values +- Multi-dimensional (uptime, success rate, quality) +- Tags, endpoints, proof-of-payment metadata +- Anti-Sybil requires client address filtering + +```solidity +struct Feedback { + int128 value; // Signed integer rating + uint8 valueDecimals; // 0-18 decimal places + string tag1; // E.g., "uptime" + string tag2; // E.g., "30days" + string endpoint; // Agent endpoint URI + string ipfsHash; // Optional metadata +} +``` + +**Example metrics:** Quality 87/100 β†’ `value=87, decimals=0`. Uptime 99.77% β†’ `value=9977, decimals=2`. + +**3. Validation Registry** +- Independent verification of agent work +- Trust models: crypto-economic (stake-secured), zkML, TEE attestation +- Validators respond with 0-100 scores + +### Agent Registration File (agentURI) + +```json +{ + "type": "https://eips.ethereum.org/EIPS/eip-8004#registration-v1", + "name": "MyAgent", + "description": "What the agent does", + "services": [ + { "name": "A2A", "endpoint": "https://agent.example/.well-known/agent-card.json", "version": "0.3.0" }, + { "name": "MCP", "endpoint": "https://mcp.agent.eth/", "version": "2025-06-18" } + ], + "x402Support": true, + "active": true, + "supportedTrust": ["reputation", "crypto-economic", "tee-attestation"] +} +``` + +### Integration + +```solidity +// Register agent +uint256 agentId = identityRegistry.register("ipfs://QmYourReg", metadata); + +// Give feedback +reputationRegistry.giveFeedback(agentId, 9977, 2, "uptime", "30days", + "https://agent.example.com/api", "ipfs://QmDetails", keccak256(data)); + +// Query reputation +(uint64 count, int128 value, uint8 decimals) = + reputationRegistry.getSummary(agentId, trustedClients, "uptime", "30days"); +``` + +### Step-by-Step: Register an Agent Onchain + +**1. Prepare the registration JSON** β€” host it on IPFS or a web server: +```json +{ + "type": "https://eips.ethereum.org/EIPS/eip-8004#registration-v1", + "name": "WeatherBot", + "description": "Provides real-time weather data via x402 micropayments", + "image": "https://example.com/weatherbot.png", + "services": [ + { "name": "A2A", "endpoint": "https://weather.example.com/.well-known/agent-card.json", "version": "0.3.0" } + ], + "x402Support": true, + "active": true, + "supportedTrust": ["reputation"] +} +``` + +**2. Upload to IPFS** (or use any URI): +```bash +# Using IPFS +ipfs add registration.json +# β†’ QmYourRegistrationHash + +# Or host at a URL β€” the agentURI just needs to resolve to the JSON +``` + +**3. Call the Identity Registry:** +```solidity +// On any supported chain β€” same address everywhere +IIdentityRegistry registry = IIdentityRegistry(0x8004A169FB4a3325136EB29fA0ceB6D2e539a432); + +// metadata bytes are optional (can be empty) +uint256 agentId = registry.register("ipfs://QmYourRegistrationHash", ""); +// agentId is your ERC-721 tokenId β€” globally unique on this chain +``` + +**4. Verify your endpoint domain** β€” place a file at `.well-known/agent-registration.json`: +```json +// https://weather.example.com/.well-known/agent-registration.json +{ + "agentId": 42, + "agentRegistry": "eip155:8453:0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "owner": "0xYourWalletAddress" +} +``` +This proves the domain owner controls the agent identity. Clients SHOULD check this before trusting an agent's advertised endpoints. + +**5. Build reputation** β€” other agents/users post feedback after interacting with your agent. + +### Cross-Chain Agent Identity + +Same contract addresses on 20+ chains means an agent registered on Base can be discovered by an agent on Arbitrum. The `agentRegistry` identifier includes the chain: + +``` +eip155:8453:0x8004A169FB4a3325136EB29fA0ceB6D2e539a432 // Base +eip155:42161:0x8004A169FB4a3325136EB29fA0ceB6D2e539a432 // Arbitrum +``` + +**Cross-chain pattern:** Register on one chain (cheapest β€” Base recommended), reference that identity from other chains. Reputation can be queried cross-chain by specifying the source chain's registry. + +**Authors:** Davide Crapis (EF), Marco De Rossi (MetaMask), Jordan Ellis (Google), Erik Reppel (Coinbase), Leonard Tan (MetaMask) + +**Ecosystem:** ENS, EigenLayer, The Graph, Taiko backing + +**Resources:** https://www.8004.org | https://eips.ethereum.org/EIPS/eip-8004 | https://github.com/erc-8004/erc-8004-contracts + +## EIP-3009: Transfer With Authorization + +You probably know the concept (gasless meta-transaction transfers). The key update: **EIP-3009 is what makes x402 work.** USDC implements it on Ethereum and most chains. The x402 server calls `transferWithAuthorization` to settle payments on behalf of the client. + +## x402: HTTP Payment Protocol + +**Status:** Production-ready open standard from Coinbase, actively deployed Q1 2026. + +Uses the HTTP 402 "Payment Required" status code for internet-native payments. + +### Flow + +``` +1. Client β†’ GET /api/data +2. Server β†’ 402 Payment Required (PAYMENT-REQUIRED header with requirements) +3. Client signs EIP-3009 payment +4. Client β†’ GET /api/data (PAYMENT-SIGNATURE header with signed payment) +5. Server verifies + settles onchain +6. Server β†’ 200 OK (PAYMENT-RESPONSE header + data) +``` + +### Payment Payload + +```json +{ + "scheme": "exact", + "network": "eip155:8453", + "amount": "1000000", + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "from": "0x...", "to": "0x...", + "signature": "0x...", + "deadline": 1234567890, + "nonce": "unique-value" +} +``` + +### x402 + ERC-8004 Synergy + +``` +Agent discovers service (ERC-8004) β†’ checks reputation β†’ calls endpoint β†’ +gets 402 β†’ signs payment (EIP-3009) β†’ server settles (x402) β†’ +agent receives service β†’ posts feedback (ERC-8004) +``` + +### x402 Server Setup (Express β€” Complete Example) + +```typescript +import express from 'express'; +import { paymentMiddleware } from '@x402/express'; + +const app = express(); + +// Define payment requirements per route +const paymentConfig = { + "GET /api/weather": { + accepts: [ + { network: "eip155:8453", token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", amount: "100000" } + // 100000 = $0.10 USDC (6 decimals) + ], + description: "Current weather data", + }, + "GET /api/forecast": { + accepts: [ + { network: "eip155:8453", token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", amount: "500000" } + // $0.50 USDC for 7-day forecast + ], + description: "7-day weather forecast", + } +}; + +// One line β€” middleware handles 402 responses, verification, and settlement +app.use(paymentMiddleware(paymentConfig)); + +app.get('/api/weather', (req, res) => { + // Only reached after payment verified + res.json({ temp: 72, condition: "sunny" }); +}); + +app.listen(3000); +``` + +### x402 Client (Agent Paying for Data) + +```typescript +import { x402Fetch } from '@x402/fetch'; +import { createWallet } from '@x402/evm'; + +const wallet = createWallet(process.env.PRIVATE_KEY); + +// x402Fetch handles the 402 β†’ sign β†’ retry flow automatically +const response = await x402Fetch('https://weather.example.com/api/weather', { + wallet, + preferredNetwork: 'eip155:8453' // Pay on Base (cheapest) +}); + +const weather = await response.json(); +// Agent paid $0.10 USDC, got weather data. No API key needed. +``` + +### Payment Schemes + +**`exact`** (live) β€” Pay a fixed price. Server knows the cost upfront. + +**`upto`** (emerging) β€” Pay up to a maximum, final amount determined after work completes. Critical for metered services: +- LLM inference: pay per token generated (unknown count upfront) +- GPU compute: pay per second of runtime +- Database queries: pay per row returned + +With `upto`, the client signs authorization for a max amount. The server settles only what was consumed. Client never overpays. + +### Facilitator Architecture + +The **facilitator** is an optional server that handles blockchain complexity so resource servers don't have to: + +``` +Client β†’ Resource Server β†’ Facilitator β†’ Blockchain + ↓ + POST /verify (check signature, balance, deadline) + POST /settle (submit tx, manage gas, confirm) +``` + +**Why use a facilitator?** Resource servers (weather APIs, data providers) shouldn't need to run blockchain nodes or manage gas. The facilitator abstracts this. Coinbase runs a public facilitator; anyone can run their own. + +**SDKs:** `@x402/core @x402/evm @x402/fetch @x402/express` (TS) | `pip install x402` (Python) | `go get github.com/coinbase/x402/go` + +**Resources:** https://www.x402.org | https://github.com/coinbase/x402 + +## EIP-7702: Smart EOAs (Live Since May 2025) + +EOAs temporarily delegate to smart contracts within a transaction. Best of both worlds: EOA simplicity + smart contract features. + +**Enables:** Batch transactions, gas sponsorship, session keys, custom auth logic β€” all for existing EOAs without migration. + +**Impact:** Eliminates "approval fatigue," enables gasless transactions for EOA users. + +## Quick Standard Reference + +| Standard | What | Status | +|----------|------|--------| +| ERC-8004 | Agent identity + reputation | βœ… Live Jan 2026 | +| x402 | HTTP payments protocol | βœ… Production Q1 2026 | +| EIP-3009 | Gasless token transfers | βœ… Live (USDC) | +| EIP-7702 | Smart EOAs | βœ… Live May 2025 | +| ERC-4337 | Account abstraction | βœ… Growing adoption | +| ERC-2612 | Gasless approvals (Permit) | βœ… Widely adopted | +| ERC-4626 | Tokenized vaults | βœ… Standard for yield | +| ERC-6551 | Token-bound accounts (NFT wallets) | βœ… Niche adoption | + +**These are all LIVE and being used in production. Not "coming soon."** diff --git a/internal/embed/skills/testing/SKILL.md b/internal/embed/skills/testing/SKILL.md new file mode 100644 index 0000000..b889cd6 --- /dev/null +++ b/internal/embed/skills/testing/SKILL.md @@ -0,0 +1,379 @@ +--- +name: testing +description: Smart contract testing with Foundry β€” unit tests, fuzz testing, fork testing, invariant testing. What to test, what not to test, and what LLMs get wrong. +--- + +# Smart Contract Testing + +## What You Probably Got Wrong + +**You test getters and trivial functions.** Testing that `name()` returns the name is worthless. Test edge cases, failure modes, and economic invariants β€” the things that lose money when they break. + +**You don't fuzz.** `forge test` finds the bugs you thought of. Fuzzing finds the ones you didn't. If your contract does math, fuzz it. If it handles user input, fuzz it. If it moves value, definitely fuzz it. + +**You don't fork-test.** If your contract calls Uniswap, Aave, or any external protocol, test against their real deployed contracts on a fork. Mocking them hides integration bugs that only appear with real state. + +**You write tests that mirror the implementation.** Testing that `deposit(100)` sets `balance[user] = 100` is tautological β€” you're testing that Solidity assignments work. Test properties: "after deposit and withdraw, user gets their tokens back." Test invariants: "total deposits always equals contract balance." + +**You skip invariant testing for stateful protocols.** If your contract has multiple interacting functions that change state over time (vaults, AMMs, lending), you need invariant tests. Unit tests check one path; invariant tests check that properties hold across thousands of random sequences. + +--- + +## Unit Testing with Foundry + +### Test File Structure + +```solidity +// test/MyContract.t.sol +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test, console} from "forge-std/Test.sol"; +import {MyToken} from "../src/MyToken.sol"; + +contract MyTokenTest is Test { + MyToken public token; + address public alice = makeAddr("alice"); + address public bob = makeAddr("bob"); + + function setUp() public { + token = new MyToken("Test", "TST", 1_000_000e18); + // Give alice some tokens for testing + token.transfer(alice, 10_000e18); + } + + function test_TransferUpdatesBalances() public { + vm.prank(alice); + token.transfer(bob, 1_000e18); + + assertEq(token.balanceOf(alice), 9_000e18); + assertEq(token.balanceOf(bob), 1_000e18); + } + + function test_TransferEmitsEvent() public { + vm.expectEmit(true, true, false, true); + emit Transfer(alice, bob, 500e18); + + vm.prank(alice); + token.transfer(bob, 500e18); + } + + function test_RevertWhen_TransferExceedsBalance() public { + vm.prank(alice); + vm.expectRevert(); + token.transfer(bob, 999_999e18); // More than alice has + } + + function test_RevertWhen_TransferToZeroAddress() public { + vm.prank(alice); + vm.expectRevert(); + token.transfer(address(0), 100e18); + } +} +``` + +### Key Assertion Patterns + +```solidity +// Equality +assertEq(actual, expected); +assertEq(actual, expected, "descriptive error message"); + +// Comparisons +assertGt(a, b); // a > b +assertGe(a, b); // a >= b +assertLt(a, b); // a < b +assertLe(a, b); // a <= b + +// Approximate equality (for math with rounding) +assertApproxEqAbs(actual, expected, maxDelta); +assertApproxEqRel(actual, expected, maxPercentDelta); // in WAD (1e18 = 100%) + +// Revert expectations +vm.expectRevert(); // Any revert +vm.expectRevert("Insufficient balance"); // Specific message +vm.expectRevert(MyContract.CustomError.selector); // Custom error + +// Event expectations +vm.expectEmit(true, true, false, true); // (topic1, topic2, topic3, data) +emit MyEvent(expectedArg1, expectedArg2); +``` + +### What to Actually Test + +```solidity +// βœ… TEST: Edge cases that lose money +function test_TransferZeroAmount() public { /* ... */ } +function test_TransferEntireBalance() public { /* ... */ } +function test_TransferToSelf() public { /* ... */ } +function test_ApproveOverwrite() public { /* ... */ } +function test_TransferFromWithExactAllowance() public { /* ... */ } + +// βœ… TEST: Access control +function test_RevertWhen_NonOwnerCallsAdminFunction() public { /* ... */ } +function test_OwnerCanPause() public { /* ... */ } + +// βœ… TEST: Failure modes +function test_RevertWhen_DepositZero() public { /* ... */ } +function test_RevertWhen_WithdrawMoreThanDeposited() public { /* ... */ } +function test_RevertWhen_ContractPaused() public { /* ... */ } + +// ❌ DON'T TEST: OpenZeppelin internals +// function test_NameReturnsName() β€” they already tested this +// function test_SymbolReturnsSymbol() β€” waste of time +// function test_DecimalsReturns18() β€” it does, trust it +``` + +--- + +## Fuzz Testing + +Foundry automatically fuzzes any test function with parameters. Instead of testing one value, it tests hundreds of random values. + +### Basic Fuzz Test + +```solidity +// Foundry calls this with random amounts +function testFuzz_DepositWithdrawRoundtrip(uint256 amount) public { + // Bound input to valid range + amount = bound(amount, 1, token.balanceOf(alice)); + + uint256 balanceBefore = token.balanceOf(alice); + + vm.startPrank(alice); + token.approve(address(vault), amount); + vault.deposit(amount, alice); + vault.withdraw(vault.balanceOf(alice), alice, alice); + vm.stopPrank(); + + // Property: user gets back what they deposited (minus any fees) + assertGe(token.balanceOf(alice), balanceBefore - 1); // Allow 1 wei rounding +} +``` + +### Bounding Inputs + +```solidity +// bound() is preferred over vm.assume() β€” bound reshapes, assume discards +function testFuzz_Fee(uint256 amount, uint256 feeBps) public { + amount = bound(amount, 1e6, 1e30); // Reasonable token amounts + feeBps = bound(feeBps, 1, 10_000); // 0.01% to 100% + + uint256 fee = (amount * feeBps) / 10_000; + uint256 afterFee = amount - fee; + + // Property: fee + remainder always equals original + assertEq(fee + afterFee, amount); +} + +// vm.assume() discards inputs β€” use sparingly +function testFuzz_Division(uint256 a, uint256 b) public { + vm.assume(b > 0); // Skip zero (would revert) + // ... +} +``` + +### Run with More Iterations + +```bash +# Default: 256 runs +forge test + +# More thorough: 10,000 runs +forge test --fuzz-runs 10000 + +# Set in foundry.toml for CI +# [fuzz] +# runs = 1000 +``` + +--- + +## Fork Testing + +Test your contract against real deployed protocols on a mainnet fork. This catches integration bugs that mocks can't. + +### Basic Fork Test + +```solidity +contract SwapTest is Test { + // Real mainnet addresses + address constant UNISWAP_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45; + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + + function setUp() public { + // Fork mainnet at a specific block for reproducibility + vm.createSelectFork("mainnet", 19_000_000); + } + + function test_SwapETHForUSDC() public { + address user = makeAddr("user"); + vm.deal(user, 1 ether); + + vm.startPrank(user); + + // Build swap path + ISwapRouter.ExactInputSingleParams memory params = ISwapRouter + .ExactInputSingleParams({ + tokenIn: WETH, + tokenOut: USDC, + fee: 3000, + recipient: user, + amountIn: 0.1 ether, + amountOutMinimum: 0, // In production, NEVER set to 0 + sqrtPriceLimitX96: 0 + }); + + // Execute swap + uint256 amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle{value: 0.1 ether}(params); + + vm.stopPrank(); + + // Verify we got USDC back + assertGt(amountOut, 0, "Should receive USDC"); + assertGt(IERC20(USDC).balanceOf(user), 0); + } +} +``` + +### When to Fork-Test + +- **Always:** Any contract that calls an external protocol (Uniswap, Aave, Chainlink) +- **Always:** Any contract that handles tokens with quirks (USDT, fee-on-transfer, rebasing) +- **Always:** Any contract that reads oracle prices +- **Never:** Pure logic contracts with no external calls β€” use unit tests + +### Running Fork Tests + +```bash +# Fork from local eRPC (if running in Obol Stack with mainnet installed) +forge test --fork-url http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet + +# Fork at specific block (reproducible) +forge test --fork-url http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet --fork-block-number 19000000 + +# Set in foundry.toml to avoid CLI flags +# [rpc_endpoints] +# mainnet = "${MAINNET_RPC_URL}" +``` + +--- + +## Invariant Testing + +Invariant tests verify that properties hold across thousands of random function call sequences. Essential for stateful protocols. + +### What Are Invariants? + +Invariants are properties that must ALWAYS be true, no matter what sequence of actions users take: + +- "Total supply equals sum of all balances" (ERC-20) +- "Total deposits equals total shares times share price" (vault) +- "x * y >= k after every swap" (AMM) +- "User can always withdraw what they deposited" (escrow) + +### Basic Invariant Test + +```solidity +contract VaultInvariantTest is Test { + MyVault public vault; + IERC20 public token; + VaultHandler public handler; + + function setUp() public { + token = new MockERC20("Test", "TST", 18); + vault = new MyVault(token); + handler = new VaultHandler(vault, token); + + // Tell Foundry which contract to call randomly + targetContract(address(handler)); + } + + // This runs after every random sequence + function invariant_TotalAssetsMatchesBalance() public view { + assertEq( + vault.totalAssets(), + token.balanceOf(address(vault)), + "Total assets must equal actual balance" + ); + } + + function invariant_SharePriceNeverZero() public view { + if (vault.totalSupply() > 0) { + assertGt(vault.convertToAssets(1e18), 0, "Share price must never be zero"); + } + } +} + +// Handler: guided random actions +contract VaultHandler is Test { + MyVault public vault; + IERC20 public token; + + constructor(MyVault _vault, IERC20 _token) { + vault = _vault; + token = _token; + } + + function deposit(uint256 amount) public { + amount = bound(amount, 1, 1e24); + deal(address(token), msg.sender, amount); + + vm.startPrank(msg.sender); + token.approve(address(vault), amount); + vault.deposit(amount, msg.sender); + vm.stopPrank(); + } + + function withdraw(uint256 shares) public { + uint256 maxShares = vault.balanceOf(msg.sender); + if (maxShares == 0) return; + shares = bound(shares, 1, maxShares); + + vm.prank(msg.sender); + vault.redeem(shares, msg.sender, msg.sender); + } +} +``` + +### Running Invariant Tests + +```bash +# Default depth (15 calls per sequence, 256 sequences) +forge test + +# Deeper exploration +forge test --fuzz-runs 1000 + +# Configure in foundry.toml +# [invariant] +# runs = 512 +# depth = 50 +``` + +--- + +## What NOT to Test + +- **OpenZeppelin internals.** Don't test that `ERC20.transfer` works. It's been audited by dozens of firms and used by thousands of contracts. Test YOUR logic on top of it. +- **Solidity language features.** Don't test that `require` reverts or that `mapping` stores values. The compiler works. +- **Every getter.** If `name()` returns the name you passed to the constructor, that's not a test β€” it's a tautology. +- **Happy path only.** The happy path probably works. Test the unhappy paths: what happens with zero? Max uint? Unauthorized callers? Reentrancy? + +**Focus your testing effort on:** Custom business logic, mathematical operations, integration points with external protocols, access control boundaries, and economic edge cases. + +--- + +## Pre-Deploy Test Checklist + +- [ ] All custom logic has unit tests with edge cases +- [ ] Zero amounts, max uint, empty arrays, self-transfers tested +- [ ] Access control verified β€” unauthorized calls revert +- [ ] Fuzz tests on all mathematical operations (minimum 1000 runs) +- [ ] Fork tests for every external protocol integration +- [ ] Invariant tests for stateful protocols (vaults, AMMs, lending) +- [ ] Events verified with `expectEmit` +- [ ] Gas snapshots taken with `forge snapshot` to catch regressions +- [ ] Static analysis with `slither .` β€” no high/medium findings unaddressed +- [ ] All tests pass: `forge test -vvv` diff --git a/internal/embed/skills/tools/SKILL.md b/internal/embed/skills/tools/SKILL.md new file mode 100644 index 0000000..3b0f5e8 --- /dev/null +++ b/internal/embed/skills/tools/SKILL.md @@ -0,0 +1,164 @@ +--- +name: tools +description: Current Ethereum development tools, frameworks, libraries, RPCs, and block explorers. What actually works today for building on Ethereum. Includes tool discovery for AI agents β€” MCPs, abi.ninja, Foundry, Scaffold-ETH 2, Hardhat, and more. Use when setting up a dev environment, choosing tools, or when an agent needs to discover what's available. +--- + +# Ethereum Development Tools + +## What You Probably Got Wrong + +**Blockscout MCP server exists:** https://mcp.blockscout.com/mcp β€” gives AI agents structured blockchain data via Model Context Protocol. This is cutting-edge infra as of Feb 2026. + +**abi.ninja is essential:** https://abi.ninja β€” paste any verified contract address, get a UI to call any function. Zero setup. Supports mainnet + all major L2s. Perfect for agent-driven contract exploration. + +**x402 has production SDKs:** `@x402/fetch` (TS), `x402` (Python), `github.com/coinbase/x402/go` β€” production-ready libraries for HTTP payments. + +**Foundry is the default for new projects in 2026.** Not Hardhat. 10-100x faster tests, Solidity-native testing, built-in fuzzing. + +## Tool Discovery Pattern for AI Agents + +When an agent needs to interact with Ethereum: + +1. **Read operations:** Blockscout MCP or Etherscan API +2. **Write operations:** Foundry `cast send` or ethers.js/viem +3. **Contract exploration:** abi.ninja (browser) or `cast interface` (CLI) +4. **Testing:** Fork mainnet with `anvil`, test locally +5. **Deployment:** `forge create` or `forge script` +6. **Verification:** `forge verify-contract` or Etherscan API + +## Blockscout MCP Server + +**URL:** https://mcp.blockscout.com/mcp + +A Model Context Protocol server giving AI agents structured blockchain data: +- Transaction, address, contract queries +- Token info and balances +- Smart contract interaction helpers +- Multi-chain support +- Standardized interface optimized for LLM consumption + +**Why this matters:** Instead of scraping Etherscan or making raw API calls, agents get structured, type-safe blockchain data via MCP. + +## abi.ninja + +**URL:** https://abi.ninja β€” Paste any contract address β†’ interact with all functions. Multi-chain. Zero setup. + +## x402 SDKs (HTTP Payments) + +**TypeScript:** +```bash +npm install @x402/core @x402/evm @x402/fetch @x402/express +``` + +```typescript +import { x402Fetch } from '@x402/fetch'; +import { createWallet } from '@x402/evm'; + +const wallet = createWallet(privateKey); +const response = await x402Fetch('https://api.example.com/data', { + wallet, + preferredNetwork: 'eip155:8453' // Base +}); +``` + +**Python:** `pip install x402` +**Go:** `go get github.com/coinbase/x402/go` +**Docs:** https://www.x402.org | https://github.com/coinbase/x402 + +## Scaffold-ETH 2 + +- **Setup:** `npx create-eth@latest` +- **What:** Full-stack Ethereum toolkit: Solidity + Next.js + Foundry +- **Key feature:** Auto-generates TypeScript types from contracts. Scaffold hooks make contract interaction trivial. +- **Deploy to IPFS:** `yarn ipfs` (BuidlGuidl IPFS) +- **UI Components:** https://ui.scaffoldeth.io/ +- **Docs:** https://docs.scaffoldeth.io/ + +## Choosing Your Stack (2026) + +| Need | Tool | +|------|------| +| Rapid prototyping / full dApps | **Scaffold-ETH 2** | +| Contract-focused dev | **Foundry** (forge + cast + anvil) | +| Quick contract interaction | **abi.ninja** (browser) or **cast** (CLI) | +| React frontends | **wagmi + viem** (or SE2 which wraps these) | +| Agent blockchain reads | **Blockscout MCP** | +| Agent payments | **x402 SDKs** | + +## Essential Foundry cast Commands + +```bash +# Read contract +cast call 0xAddr "balanceOf(address)(uint256)" 0xWallet --rpc-url $RPC + +# Send transaction +cast send 0xAddr "transfer(address,uint256)" 0xTo 1000000 --private-key $KEY --rpc-url $RPC + +# Gas price +cast gas-price --rpc-url $RPC + +# Decode calldata +cast 4byte-decode 0xa9059cbb... + +# ENS resolution +cast resolve-name vitalik.eth --rpc-url $RPC + +# Fork mainnet locally +anvil --fork-url $RPC +``` + +## RPC Providers + +**Obol Stack (local, preferred):** +- `http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet` β€” local eRPC gateway, routes to installed networks +- Supports `/rpc/{network}` (mainnet, hoodi, sepolia) and `/rpc/evm/{chainId}` routing + +**Free (testing/fallback):** +- `https://eth.llamarpc.com` β€” LlamaNodes, no key +- `https://rpc.ankr.com/eth` β€” Ankr, free tier + +**Paid (production):** +- **Alchemy** β€” most popular, generous free tier (300M CU/month) +- **Infura** β€” established, MetaMask default +- **QuickNode** β€” performance-focused + +**Community:** `rpc.buidlguidl.com` + +## Block Explorers + +| Network | Explorer | API | +|---------|----------|-----| +| Mainnet | https://etherscan.io | https://api.etherscan.io | +| Arbitrum | https://arbiscan.io | Etherscan-compatible | +| Base | https://basescan.org | Etherscan-compatible | +| Optimism | https://optimistic.etherscan.io | Etherscan-compatible | + +## MCP Servers for Agents + +**Model Context Protocol** β€” standard for giving AI agents structured access to external systems. + +1. **Blockscout MCP** β€” multi-chain blockchain data (primary) +2. **eth-mcp** β€” community Ethereum RPC via MCP +3. **Custom MCP wrappers** emerging for DeFi protocols, ENS, wallets + +MCP servers are composable β€” agents can use multiple together. + +## What Changed in 2025-2026 + +- **Foundry became default** over Hardhat for new projects +- **Viem gaining on ethers.js** (smaller, better TypeScript) +- **MCP servers emerged** for agent-blockchain interaction +- **x402 SDKs** went production-ready +- **ERC-8004 tooling** emerging (agent registration/discovery) +- **Deprecated:** Truffle (use Foundry/Hardhat), Goerli/Rinkeby (use Sepolia) + +## Testing Essentials + +**Fork mainnet locally:** +```bash +anvil --fork-url http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet +# Now test against real contracts with fake ETH at http://localhost:8545 +# Fallback public RPC: https://eth.llamarpc.com +``` + +**Primary testnet:** Sepolia (Chain ID: 11155111). Goerli and Rinkeby are deprecated. diff --git a/internal/embed/skills/wallets/SKILL.md b/internal/embed/skills/wallets/SKILL.md new file mode 100644 index 0000000..8f8499b --- /dev/null +++ b/internal/embed/skills/wallets/SKILL.md @@ -0,0 +1,168 @@ +--- +name: wallets +description: How to create, manage, and use Ethereum wallets. Covers EOAs, smart contract wallets, multisig (Safe), and account abstraction. Essential for any AI agent that needs to interact with Ethereum β€” sending transactions, signing messages, or managing funds. Includes guardrails for safe key handling. +--- + +# Wallets on Ethereum + +## What You Probably Got Wrong + +**EIP-7702 is live.** Since Pectra (May 7, 2025), regular EOAs can temporarily delegate to smart contracts β€” getting batch transactions, gas sponsorship, and session keys without migrating wallets. This is NOT "coming soon." It shipped. + +**Account abstraction status:** ERC-4337 is growing but still early (Feb 2026). Major implementations: Kernel (ZeroDev), Biconomy, Alchemy Account Kit, Pimlico. EntryPoint v0.7: `0x0000000071727De22E5E9d8BAf0edAc6f37da032`. + +**Safe secures $100B+.** It's not just a dev tool β€” it's the dominant multisig for institutional and DAO treasury management. + +## EIP-7702: Smart EOAs (Live Since May 2025) + +EOAs can **temporarily delegate control to a smart contract** within a single transaction. + +**How it works:** +1. EOA signs an authorization to delegate to a contract +2. During transaction, EOA's code becomes the contract's code +3. Contract executes complex logic (batching, sponsorship, etc.) +4. After transaction, EOA returns to normal + +**What this enables:** +- Batch 10 token approvals into one transaction +- Gas sponsorship / meta-transactions for EOA users +- Session keys with limited permissions +- Custom authorization logic +- Eliminates "approval fatigue" (approve + execute β†’ one step) + +**Status (Feb 2026):** Deployed on mainnet. MetaMask, Rainbow adding support. Still early for production agents β€” use standard EOAs or Safe until tooling matures. + +## Safe (Gnosis Safe) Multisig + +### Key Addresses (v1.4.1, deterministic across chains) + +| Contract | Address | +|----------|---------| +| Safe Singleton | `0x41675C099F32341bf84BFc5382aF534df5C7461a` | +| Safe Proxy Factory | `0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67` | +| MultiSend | `0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526` | + +Same addresses on Mainnet, Arbitrum, Base, and all major chains. + +### Safe for AI Agents + +**Pattern:** 1-of-2 Safe +- Owner 1: Agent's wallet (hot, automated) +- Owner 2: Human's wallet (cold, recovery) +- Threshold: 1 (agent can act alone) + +Benefits: If agent key is compromised, human removes it. Human can always recover funds. Agent can batch transactions. + +## 🚨 NEVER COMMIT SECRETS TO GIT + +**This is the #1 way AI agents lose funds and leak credentials.** Bots scrape GitHub in real-time and exploit leaked secrets within seconds β€” even from private repos, even if deleted immediately. A secret committed to Git is compromised forever. + +**This happens constantly with AI coding agents.** The agent generates a deploy script, hardcodes a key, runs `git add .`, and the wallet is drained before the next prompt. Or the agent pastes an Alchemy API key into `scaffold.config.ts` and it ends up in a public repo. + +**This applies to ALL secrets:** +- **Wallet private keys** β€” funds drained instantly +- **API keys** β€” Alchemy, Infura, Etherscan, WalletConnect +- **RPC URLs with embedded keys** β€” `https://base-mainnet.g.alchemy.com/v2/YOUR_KEY` +- **OAuth tokens, bearer tokens, passwords** + +### Prevention + +```bash +# .gitignore (MUST exist in every project) +.env +.env.* +*.key +*.pem +broadcast/ +cache/ +``` + +```bash +# Verify before every commit +git diff --cached --name-only | grep -iE '\.env|key|secret|private' +# If this matches ANYTHING, stop and fix it + +# Nuclear option: scan entire repo history +git log --all -p | grep -iE 'private.?key|0x[a-fA-F0-9]{64}' +``` + +### If You Already Committed a Key + +1. **Assume it's compromised.** Don't hope nobody saw it. +2. **Transfer all funds immediately** to a new wallet. +3. **Rotate the key.** Generate a new one. The old one is burned forever. +4. **Clean Git history** with `git filter-repo` or BFG Repo Cleaner β€” but this is damage control, not prevention. The key is already compromised. +5. **Revoke any token approvals** from the compromised address. + +### Safe Patterns for AI Agents + +```bash +# Load key from environment (NEVER hardcode) +cast send ... --private-key $DEPLOYER_PRIVATE_KEY + +# Or use encrypted keystore +cast send ... --keystore ~/.foundry/keystores/deployer --password-file .password + +# Or use hardware wallet +cast send ... --ledger +``` + +**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. + +## CRITICAL Guardrails for AI Agents + +### Key Safety Rules + +1. **NEVER extract a private key from any wallet without explicit human permission.** +2. **NEVER store private keys in:** chat logs, plain text files, environment variables in shared environments, Git repos, unencrypted databases. +3. **NEVER move funds without human confirmation.** Show: amount, destination (checksummed), gas cost, what it does. Wait for explicit "yes." +4. **Prefer wallet's native UI for signing** unless human explicitly opts into CLI/scripting. +5. **Use a dedicated wallet with limited funds** for agent operations. Never the human's main wallet. +6. **Double-check addresses.** Use `ethers.getAddress()` or equivalent for checksum validation. A single wrong character = permanent loss. +7. **Test on testnet first.** Or use local Anvil fork. +8. **Implement spending limits.** Require human approval above threshold. Use Safe multisig for high-value operations. +9. **Log all transactions (never keys).** Keep audit trail. +10. **Assume keys will be compromised.** Design so a compromised agent key doesn't mean total loss. + +### Storage Options (Worst to Best) + +❌ Plain text in code/logs β€” NEVER +❌ Environment variables in shared environments β€” NEVER +❌ Committed to Git β€” NEVER +⚠️ Local `.env` file β€” testing only +βœ… Encrypted keystore (password-protected) +βœ… Hardware wallet / Cloud KMS / TEE + +### Safe Transaction Pattern + +```javascript +async function sendSafely(wallet, to, value) { + const checksummedTo = ethers.getAddress(to); // validates + const gasEstimate = await wallet.estimateGas({ to: checksummedTo, value }); + const feeData = await wallet.provider.getFeeData(); + const gasCost = gasEstimate * feeData.maxFeePerGas; + const totalCostUSD = Number(ethers.formatEther(value + gasCost)) * 1960; + + if (totalCostUSD > 10) { + // Show details and wait for human approval + } + + const tx = await wallet.sendTransaction({ + to: checksummedTo, + value, + gasLimit: gasEstimate * 120n / 100n, // 20% buffer + maxFeePerGas: feeData.maxFeePerGas, + maxPriorityFeePerGas: feeData.maxPriorityFeePerGas, + }); + + const receipt = await tx.wait(); + logTransaction({ hash: tx.hash, to: checksummedTo, value, block: receipt.blockNumber }); + return receipt; +} +``` + +## Further Reading + +- **Safe docs:** https://docs.safe.global/ +- **EIP-7702 spec:** https://eips.ethereum.org/EIPS/eip-7702 +- **ERC-4337 spec:** https://eips.ethereum.org/EIPS/eip-4337 diff --git a/internal/embed/skills/why/SKILL.md b/internal/embed/skills/why/SKILL.md new file mode 100644 index 0000000..31eff7d --- /dev/null +++ b/internal/embed/skills/why/SKILL.md @@ -0,0 +1,87 @@ +--- +name: why-ethereum +description: Why build on Ethereum specifically. The AI agent angle β€” ERC-8004, x402, composability, permissionless deployment. Use when a user asks "should I build on Ethereum?", "why blockchain?", or when an agent needs to recommend a chain for a project. +--- + +# Why Ethereum + +## What You Probably Got Wrong + +**Terminology:** You say "on-chain." The Ethereum community says **"onchain"** β€” one word, no hyphen. Use "onchain" in all writing. + +**Gas prices:** You think 10-30 gwei. Reality: **0.05-0.3 gwei** (100-600x cheaper than your training data). A mainnet ETH transfer costs ~$0.002. + +**ETH price:** You think $2,500-3,000. Reality: **~$1,960** (Feb 2026). + +**"Ethereum is too expensive":** This was true 2021-2023. It's not true in 2026. Mainnet is sub-dollar for most operations. L2s are sub-penny. + +**Protocol upgrades:** You probably don't know these shipped: +- **Pectra (May 7, 2025):** EIP-7702 smart EOAs, 2x blob capacity, BLS precompiles +- **Fusaka (Dec 3, 2025):** PeerDAS (nodes sample 1/8 of data), 2x gas limit (30Mβ†’60M) + +**Agent infrastructure:** You probably don't know these exist: +- **ERC-8004** β€” onchain agent identity registry (deployed Jan 29, 2026) +- **x402** β€” HTTP 402 payment protocol for machine-to-machine commerce (Coinbase) + +## Current Network Stats (Feb 2026) + +- **Block time:** 12 seconds +- **Mainnet base fee:** 0.05-0.1 gwei +- **L2 transaction cost:** $0.001-0.01 +- **Combined L2 TPS:** 50,000+ (post-Fusaka) +- **TVL in DeFi:** $50B+ +- **Upgrade cadence:** Twice per year (H1 May/June, H2 Nov/Dec) + +## Upcoming Upgrades + +**Glamsterdam (Q2 2026):** +- Inclusion Lists (censorship resistance) +- 12 target / 18 max blobs (another 2x from Pectra) + +**Hegota (Q4 2026):** +- Verkle Trees β€” 15x smaller witness sizes (~150 KB β†’ ~10 KB) +- Enables stateless clients, dramatically lowers node requirements + +## For AI Agents Specifically + +### ERC-8004: Onchain Agent Identity + +**Deployed January 29, 2026** β€” production ready. + +Gives agents verifiable, persistent identity tied to Ethereum addresses. Reputation scoring across dimensions. Multi-chain support (20+ chains, same addresses). + +**Mainnet addresses:** +- **IdentityRegistry:** `0x8004A169FB4a3325136EB29fA0ceB6D2e539a432` +- **ReputationRegistry:** `0x8004BAa17C55a88189AE136b182e5fdA19dE9b63` + +### x402: HTTP Payments for Agents + +**Production-ready, actively deployed Q1 2026.** + +Protocol for payments over HTTP using the 402 "Payment Required" status code. Agent calls API β†’ gets 402 β†’ signs EIP-3009 payment β†’ retries with payment header β†’ gets response. No API keys, no accounts, just cryptographic payments. + +**SDKs:** TypeScript (`@x402/fetch`), Python (`x402`), Go (`github.com/coinbase/x402/go`) + +### ERC-8004 + x402 = Autonomous Agent Economy + +``` +1. Agent discovers service (ERC-8004 registry) +2. Agent checks reputation (ERC-8004) +3. Agent calls endpoint β†’ 402 Payment Required +4. Agent pays via x402 (EIP-3009 signature) +5. Agent receives service +6. Agent posts feedback to ERC-8004 +``` + +Fully autonomous economic loop β€” no humans required. + +### Why Ethereum's Permissionless Nature Matters for Agents + +Agents can deploy contracts, interact with any protocol, and transact 24/7 without anyone's permission. No API keys to revoke, no accounts to ban, no services to shut down. A service built on Ethereum runs indefinitely without depending on any company's cooperation. + +## Resources + +- **ERC-8004:** https://www.8004.org | https://eips.ethereum.org/EIPS/eip-8004 +- **x402:** https://www.x402.org | https://github.com/coinbase/x402 +- **Blockscout MCP:** https://mcp.blockscout.com/mcp +- **DeFi Llama:** https://defillama.com From 53517af1499d7fbc97dbcb5e066c258b82dd58a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Sun, 22 Feb 2026 14:27:44 +0000 Subject: [PATCH 08/14] Update wallet infra --- cmd/obol/main.go | 2 +- internal/embed/skills/addresses/SKILL.md | 28 ++- internal/openclaw/skills_injection_test.go | 4 +- internal/openclaw/web3signer.go | 48 ++++- internal/openclaw/web3signer_test.go | 43 +++++ plans/ethereum-wallet-web3signer.md | 209 +++++++++------------ 6 files changed, 207 insertions(+), 127 deletions(-) diff --git a/cmd/obol/main.go b/cmd/obol/main.go index f2b3008..a7a5299 100644 --- a/cmd/obol/main.go +++ b/cmd/obol/main.go @@ -61,7 +61,7 @@ COMMANDS: openclaw token Retrieve gateway token openclaw list List instances openclaw delete Remove instance and cluster resources - openclaw skills Manage skills (sync from local dir) + openclaw skills Manage skills (add skills from Clawhub) Model Providers: model setup Configure cloud AI provider in llmspy gateway diff --git a/internal/embed/skills/addresses/SKILL.md b/internal/embed/skills/addresses/SKILL.md index 9df896e..e0d42bf 100644 --- a/internal/embed/skills/addresses/SKILL.md +++ b/internal/embed/skills/addresses/SKILL.md @@ -1,6 +1,6 @@ --- name: addresses -description: Verified contract addresses for major Ethereum protocols across mainnet and L2s. Use this instead of guessing or hallucinating addresses. Includes Uniswap, Aave, Compound, Aerodrome, GMX, Pendle, Velodrome, Camelot, SyncSwap, Lido, Rocket Pool, 1inch, Permit2, MakerDAO/sDAI, EigenLayer, Across, Chainlink CCIP, Yearn V3, USDC, USDT, DAI, ENS, Safe, Chainlink, and more. Always verify addresses against a block explorer before sending transactions. +description: Verified contract addresses for major Ethereum protocols across mainnet and L2s. Use this instead of guessing or hallucinating addresses. Includes Obol Splits, Uniswap, Aave, Compound, Aerodrome, GMX, Pendle, Velodrome, Camelot, SyncSwap, Lido, Rocket Pool, 1inch, Permit2, MakerDAO/sDAI, EigenLayer, Across, Chainlink CCIP, Yearn V3, USDC, USDT, DAI, ENS, Safe, Chainlink, and more. Always verify addresses against a block explorer before sending transactions. --- # Contract Addresses @@ -301,6 +301,31 @@ Restaking protocol. Both are upgradeable proxies (EIP-1967). Source: [eigenlayer.xyz](https://docs.eigenlayer.xyz/) +### Obol Splits β€” Factory Contracts + +Obol's reward-splitting infrastructure for distributed validators. Factory contracts deploy per-cluster split instances. + +#### Obol Validator Manager Factory +| Chain | Address | Status | +|-------|---------|--------| +| Mainnet | `0x2c26B5A373294CaccBd3DE817D9B7C6aea7De584` | βœ… Verified | +| Hoodi | `0x5754C8665B7e7BF15E83fCdF6d9636684B782b12` | βœ… Verified | +| Sepolia | `0xF32F8B563d8369d40C45D5d667C2E26937F2A3d3` | βœ… Verified | + +#### Obol Lido Split Factory +| Chain | Address | Status | +|-------|---------|--------| +| Hoodi | `0xb633CD420aF83E8A5172e299104842b63dd97ab7` | βœ… Verified | + +#### Optimistic Withdrawal Recipient (OWR) Factory +| Chain | Address | Status | +|-------|---------|--------| +| Mainnet | `0x119acd7844cbdd5fc09b1c6a4408f490c8f7f522` | βœ… Verified | +| Hoodi | `0x9ff0c649d0bf5fe7efa4d72e94bed7302ed5c8d7` | βœ… Verified | +| Sepolia | `0xca78f8fda7ec13ae246e4d4cd38b9ce25a12e64a` | βœ… Verified | + +Source: [docs.obol.org/learn/readme/obol-splits#deployments](https://docs.obol.org/learn/readme/obol-splits#deployments) + ### Chainlink CCIP Router (v1.2.0) Cross-chain messaging. Call `typeAndVersion()` to confirm β€” returns "Router 1.2.0". @@ -526,6 +551,7 @@ cast code 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --rpc-url http://erpc.erpc. - **Rocket Pool:** https://docs.rocketpool.net/overview/contracts-integrations - **1inch:** https://docs.1inch.io/docs/aggregation-protocol/introduction - **EigenLayer:** https://docs.eigenlayer.xyz/ +- **Obol Splits:** https://docs.obol.org/learn/readme/obol-splits#deployments - **Across:** https://docs.across.to/reference/contract-addresses - **Chainlink CCIP:** https://docs.chain.link/ccip/directory/mainnet - **Yearn V3:** https://docs.yearn.fi/developers/addresses/v3-contracts diff --git a/internal/openclaw/skills_injection_test.go b/internal/openclaw/skills_injection_test.go index a43893c..02e31b0 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", "local-wallet", "obol-stack"} { + for _, skill := range []string{"distributed-validators", "ethereum-networks", "ethereum-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", "local-wallet", "obol-stack"} { + for _, skill := range []string{"distributed-validators", "ethereum-networks", "ethereum-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 index 542570e..8b73d9b 100644 --- a/internal/openclaw/web3signer.go +++ b/internal/openclaw/web3signer.go @@ -203,7 +203,11 @@ func splitLines(s string) []string { func applyWeb3SignerMetadata(cfg *config.Config, id string) { keysDir := Web3SignerKeysPath(cfg, id) - // Find the .hex key file to reconstruct the key info + // Find a .yaml key file to reconstruct the key info. + // Key files are YAML with an inline privateKey field: + // type: "file-raw" + // keyType: "SECP256K1" + // privateKey: "0xabcdef..." entries, err := os.ReadDir(keysDir) if err != nil { fmt.Printf(" Warning: could not read web3signer keys directory: %v\n", err) @@ -211,20 +215,27 @@ func applyWeb3SignerMetadata(cfg *config.Config, id string) { } for _, entry := range entries { - if filepath.Ext(entry.Name()) != ".hex" { + if filepath.Ext(entry.Name()) != ".yaml" { continue } - keyID := strings.TrimSuffix(entry.Name(), ".hex") + keyID := strings.TrimSuffix(entry.Name(), ".yaml") - privHex, err := os.ReadFile(filepath.Join(keysDir, entry.Name())) + content, 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))) + // Extract the privateKey value from the YAML content. + privHex := extractPrivateKeyFromYAML(string(content)) + if privHex == "" { + fmt.Printf(" Warning: no privateKey found in %s\n", entry.Name()) + continue + } + + privBytes, err := hex.DecodeString(privHex) if err != nil { - fmt.Printf(" Warning: invalid key hex: %v\n", err) + fmt.Printf(" Warning: invalid key hex in %s: %v\n", entry.Name(), err) continue } @@ -245,12 +256,33 @@ func applyWeb3SignerMetadata(cfg *config.Config, id string) { 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) + fmt.Printf("βœ“ Web3Signer metadata published (Agent address: %s)\n", key.Address) + fmt.Printf(" Back up your key: cp -r %s/ ~/obol-wallet-backup-%s/\n", keysDir, id) + fmt.Println(" WARNING: This wallet feature is in alpha and may change rapidly.") + fmt.Println(" Do not deposit mainnet funds you are not willing to lose.") } return // only process the first key } } +// extractPrivateKeyFromYAML parses a Web3Signer key YAML file and returns +// the raw private key hex (without 0x prefix). Returns empty string if not found. +func extractPrivateKeyFromYAML(content string) string { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "privateKey:") { + continue + } + // privateKey: "0xabcdef..." + val := strings.TrimPrefix(line, "privateKey:") + val = strings.TrimSpace(val) + val = strings.Trim(val, `"'`) + val = strings.TrimPrefix(val, "0x") + return val + } + return "" +} + // 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 @@ -265,7 +297,7 @@ func ensureWeb3Signer(cfg *config.Config, id, deploymentDir string) { // 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" { + if filepath.Ext(e.Name()) == ".yaml" { return // Both values and key exist β€” nothing to do } } diff --git a/internal/openclaw/web3signer_test.go b/internal/openclaw/web3signer_test.go index 1206b7e..b48520a 100644 --- a/internal/openclaw/web3signer_test.go +++ b/internal/openclaw/web3signer_test.go @@ -252,6 +252,49 @@ func TestWeb3SignerKeysPath(t *testing.T) { } } +func TestExtractPrivateKeyFromYAML(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "standard format", + content: `type: "file-raw" +keyType: "SECP256K1" +privateKey: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" +`, + want: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + }, + { + name: "single quotes", + content: `type: "file-raw" +privateKey: '0xdeadbeef' +`, + want: "deadbeef", + }, + { + name: "no privateKey field", + content: `type: "file-raw"`, + want: "", + }, + { + name: "empty content", + content: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractPrivateKeyFromYAML(tt.content) + if got != tt.want { + t.Errorf("extractPrivateKeyFromYAML() = %q, want %q", got, tt.want) + } + }) + } +} + func TestIndentJSON(t *testing.T) { input := `{ "foo": "bar", diff --git a/plans/ethereum-wallet-web3signer.md b/plans/ethereum-wallet-web3signer.md index 6c9cb99..c6b2b09 100644 --- a/plans/ethereum-wallet-web3signer.md +++ b/plans/ethereum-wallet-web3signer.md @@ -1,7 +1,7 @@ # Ethereum Wallet: Web3Signer Integration Plan -> **Status**: Draft v2 β€” awaiting review before implementation -> **Date**: 2026-02-20 +> **Status**: Implemented β€” Phase 1-3 complete, Phase 4 in progress +> **Date**: 2026-02-22 (updated) > **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 --- @@ -101,13 +101,12 @@ Key point: `eth_signTransaction` on web3signer returns the RLP-encoded signed tr ``` 1. obol agent init -2. Go code: crypto/ecdsa.GenerateKey(secp256k1) β†’ private key +2. Go code: secp256k1.GeneratePrivateKey() β†’ private key (decred/secp256k1) 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 +4. Go code: write YAML key config β†’ $DATA_DIR/openclaw-/storage-web3signer-0/keys/.yaml +5. helmfile sync β†’ deploys web3signer with PVC mounted at key directory +6. Go code: create ConfigMap web3signer-metadata with address + public key (post-sync) +7. web3signer starts β†’ reads key files β†’ ready to sign ``` --- @@ -147,7 +146,7 @@ releases: ### 3.2 Web3Signer Values (`values-web3signer.yaml`) -Generated by `generateWeb3SignerValues()` in a new `internal/openclaw/web3signer.go`: +Generated by `generateWeb3SignerValues()` in `internal/openclaw/web3signer.go`: ```yaml # Web3Signer configuration for OpenClaw instance @@ -155,31 +154,42 @@ 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 + tag: "25.12.0" # Pin a specific stable version + +# Override the default command to use eth1 mode instead of eth2. +# The chart's _cmd.tpl hardcodes "eth2" β€” we need "eth1" for SECP256K1 signing. +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 -# Disable PostgreSQL β€” not needed for ETH1 file-based keys -postgresql: +# Slashing protection DB (PostgreSQL) not needed for ETH1 file-based keys. +slashingprotectiondb: enabled: false -# Service β€” ClusterIP only, no external exposure +# ClusterIP only β€” no external exposure. service: type: ClusterIP -# No ingress β€” web3signer is namespace-internal only +# No ingress β€” web3signer is namespace-internal only. ingress: enabled: false @@ -197,7 +207,7 @@ resources: 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/ +Host path: $DATA_DIR/openclaw-/storage-web3signer-0/keys/ ↓ (k3d volume mount + local-path-provisioner) Pod path: /data/keys/ ``` @@ -206,62 +216,42 @@ The Go code in `web3signer.go` writes key files to the host-side path at init ti **File layout on host**: ``` -$DATA_DIR/openclaw-/web3signer-data/keys/ -β”œβ”€β”€ .hex # Raw private key (64 hex chars) -└── .toml # Web3Signer key config +$DATA_DIR/openclaw-/storage-web3signer-0/keys/ +└── .yaml # Web3Signer YAML key config (v25+ format) ``` -**TOML key config format**: -```toml -[metadata] -description = "obol-agent-" - -[signing] -type = "file-raw" -filename = "/data/keys/.hex" +**YAML key config format** (Web3Signer v25+ inline format): +```yaml +type: "file-raw" +keyType: "SECP256K1" +privateKey: "0x<64 hex chars>" ``` -### 3.4 Key Generation in Go +### 3.4 Key Generation in Go (Implemented) -New function in `internal/openclaw/web3signer.go`: +Implemented in `internal/openclaw/web3signer.go` using `github.com/decred/dcrd/dcrec/secp256k1/v4`: ```go import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "encoding/hex" - + "github.com/decred/dcrd/dcrec/secp256k1/v4" "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 +func GenerateSigningKey() (*Web3SignerKey, error) { + privKey, err := secp256k1.GeneratePrivateKey() + // ... + privBytes := privKey.Serialize() // 32 bytes + pubBytes := privKey.PubKey().SerializeUncompressed() // 65 bytes: 04 || x || y - // Derive Ethereum address: keccak256(uncompressed_pubkey[1:])[12:] - pubBytes := elliptic.Marshal(key.Curve, key.PublicKey.X, key.PublicKey.Y) + // Ethereum address: keccak256(pubkey_without_prefix)[12:] 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 + hash.Write(pubBytes[1:]) // skip 0x04 prefix + addrBytes := hash.Sum(nil)[12:] + // ... } ``` -**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. +**Decision**: Used `decred/secp256k1` β€” lightweight, no transitive dependency bloat from go-ethereum. ### 3.5 ConfigMap for Frontend (Public Key Metadata) @@ -608,21 +598,24 @@ All methods use POST to the base URL with Content-Type: application/json. ## 6. Implementation Phases -### Phase 1: Key Generation & Web3Signer Deployment +### Phase 1: Key Generation & Web3Signer Deployment -- DONE -**New file**: `internal/openclaw/web3signer.go` +**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 +**Implemented Functions**: +- `GenerateSigningKey() (*Web3SignerKey, error)` β€” uses `decred/secp256k1` +- `ProvisionKeyFiles(keysDir string, key *Web3SignerKey, label string) error` β€” writes `.yaml` inline key to host-path PVC - `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/` +- `ApplyMetadataConfigMap(cfg, id string, key *Web3SignerKey) error` β€” applies `web3signer-metadata` ConfigMap via kubectl +- `Web3SignerKeysPath(cfg, id string) string` β€” returns `$DATA_DIR/openclaw-/storage-web3signer-0/keys/` +- `ensureWeb3Signer(cfg, id, deploymentDir string)` β€” idempotent provisioning for existing deployments +- `applyWeb3SignerMetadata(cfg, id string)` β€” reads key YAML, derives address, creates ConfigMap post-sync +- `extractPrivateKeyFromYAML(content string) string` β€” parses inline privateKey from YAML key file -**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 +**Modified**: `internal/openclaw/openclaw.go` +- `generateHelmfile()` β€” adds `ethereum` repo and `web3signer` release +- `Onboard()` β€” calls key generation + provisioning before `doSync()` +- `doSync()` β€” calls `applyWeb3SignerMetadata()` after helmfile sync to create ConfigMap **Acceptance criteria**: - `obol agent init` generates a key and deploys web3signer in the same namespace @@ -631,54 +624,35 @@ All methods use POST to the base URL with Content-Type: application/json. - `web3signer-metadata` ConfigMap exists with correct address data - Web3signer is NOT accessible via any HTTPRoute -### Phase 2: Skill Script β€” Accounts & Signing +### Phase 2: Skill Script β€” Accounts & Signing -- DONE -**New files**: -- `internal/embed/skills/ethereum-wallet/scripts/signer.py` +**Files created**: +- `internal/embed/skills/ethereum-wallet/scripts/signer.py` (293 lines) - `internal/embed/skills/ethereum-wallet/references/web3signer-api.md` -**Commands to implement**: +**Commands implemented**: 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 +### Phase 3: Transaction Signing & Submission -- DONE -**Modify**: `scripts/signer.py` - -**Commands to implement**: +**Commands implemented** in `scripts/signer.py`: 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 -- IN PROGRESS -### Phase 4: Skill Documentation & Polish +**Done**: +- `internal/embed/skills/ethereum-wallet/SKILL.md` β€” complete skill documentation +- `internal/openclaw/web3signer_test.go` β€” unit tests for key generation, values generation, YAML format, ConfigMap metadata, helmfile generation, key path, JSON indentation, YAML private key extraction +- `internal/openclaw/skills_injection_test.go` β€” updated for renamed skills -**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 +**Remaining**: - 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 +- `ethereum-networks` SKILL.md cross-reference to wallet skill --- @@ -692,21 +666,26 @@ All methods use POST to the base URL with Content-Type: application/json. | 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. | +| Q7 | secp256k1 in Go | Using `github.com/decred/dcrd/dcrec/secp256k1/v4` | Lightweight, no go-ethereum transitive dependency bloat. | --- ## 8. Testing Strategy -### Unit Tests (`internal/openclaw/web3signer_test.go`) +### Unit Tests (`internal/openclaw/web3signer_test.go`) -- ALL PASSING | 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 | +| `TestGenerateSigningKey_Unique` | Two generated keys are distinct | +| `TestProvisionKeyFiles` | `.yaml` file has correct structure, 0600 permissions, inline privateKey | +| `TestProvisionKeyFiles_CreatesDirectory` | Nested directory creation works | +| `TestGenerateWeb3SignerValues` | Valid YAML, ETH1 via customCommand, slashingprotectiondb disabled, ClusterIP | +| `TestMetadataPayload_JSON` | Correct JSON structure, marshal/unmarshal round-trip | +| `TestGenerateHelmfile_IncludesWeb3Signer` | Two releases (openclaw + web3signer), same namespace, ethereum repo | +| `TestWeb3SignerKeysPath` | Correct host-side path construction | +| `TestExtractPrivateKeyFromYAML` | Parses privateKey from YAML key files (standard, single-quote, missing, empty) | +| `TestIndentJSON` | JSON indentation for YAML embedding | ### Integration Tests (`internal/openclaw/integration_test.go`) @@ -733,8 +712,8 @@ All methods use POST to the base URL with Content-Type: application/json. | 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 | +| `consensys/web3signer` Docker image | 25.12.0 | Docker Hub | Pin specific version | +| `github.com/decred/dcrd/dcrec/secp256k1/v4` | v4 | Go module | Lightweight secp256k1 for key generation | | PostgreSQL (chart sub-dep) | β€” | Disabled | Not needed for ETH1 file-based keys | --- From 24696e5ffafe9878b4b8f31f0293c1ae92d03c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Sun, 22 Feb 2026 15:34:35 +0000 Subject: [PATCH 09/14] Refinements --- .../infrastructure/values/erpc.yaml.gotmpl | 4 ++ internal/embed/skills/addresses/SKILL.md | 43 +++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/internal/embed/infrastructure/values/erpc.yaml.gotmpl b/internal/embed/infrastructure/values/erpc.yaml.gotmpl index a035c2e..e187651 100644 --- a/internal/embed/infrastructure/values/erpc.yaml.gotmpl +++ b/internal/embed/infrastructure/values/erpc.yaml.gotmpl @@ -56,6 +56,10 @@ config: |- endpoint: https://{{ $erpcGcpAuth }}@erpc.gcp.obol.tech/hoodi/evm/560048 evm: chainId: 560048 + - id: allnodes-rpc-hoodi + endpoint: https://ethereum-hoodi-rpc.publicnode.com + evm: + chainId: 560048 networks: - architecture: evm evm: diff --git a/internal/embed/skills/addresses/SKILL.md b/internal/embed/skills/addresses/SKILL.md index e0d42bf..f2df715 100644 --- a/internal/embed/skills/addresses/SKILL.md +++ b/internal/embed/skills/addresses/SKILL.md @@ -1,6 +1,6 @@ --- name: addresses -description: Verified contract addresses for major Ethereum protocols across mainnet and L2s. Use this instead of guessing or hallucinating addresses. Includes Obol Splits, Uniswap, Aave, Compound, Aerodrome, GMX, Pendle, Velodrome, Camelot, SyncSwap, Lido, Rocket Pool, 1inch, Permit2, MakerDAO/sDAI, EigenLayer, Across, Chainlink CCIP, Yearn V3, USDC, USDT, DAI, ENS, Safe, Chainlink, and more. Always verify addresses against a block explorer before sending transactions. +description: Verified contract addresses for major Ethereum protocols across mainnet and L2s. Use this instead of guessing or hallucinating addresses. Includes Obol Splits, Splits.org (0xSplits), Uniswap, Aave, Compound, Aerodrome, GMX, Pendle, Velodrome, Camelot, SyncSwap, Lido, Rocket Pool, 1inch, Permit2, MakerDAO/sDAI, EigenLayer, Across, Chainlink CCIP, Yearn V3, USDC, USDT, DAI, ENS, Safe, Chainlink, and more. Always verify addresses against a block explorer before sending transactions. --- # Contract Addresses @@ -61,12 +61,15 @@ description: Verified contract addresses for major Ethereum protocols across mai | Arbitrum | `0x5979D7b546E38E414F7E9822514be443A4800529` | βœ… Verified | | Optimism | `0x1F32b1c2345538c0c6f582fCB022739c4A194Ebb` | βœ… Verified | | Base | `0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452` | βœ… Verified | +| Hoodi | `0x7E99eE3C66636DE415D2d7C880938F2f40f94De4` | βœ… Verified | ### Lido β€” Staking & Withdrawal | Contract | Address | Status | |----------|---------|--------| | stETH / Lido (deposit ETH here) | `0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84` | βœ… Verified | +| stETH / Lido (Hoodi testnet) | `0x3508A952176b3c15387C97BE809eaffB1982176a` | βœ… Verified | | Withdrawal Queue (unstETH NFT) | `0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1` | βœ… Verified | +| Withdrawal Queue (Hoodi) | `0xfe56573178f1bcdf53F01A6E9977670dcBBD9186` | βœ… Verified | ### Rocket Pool | Contract | Address | Status | @@ -303,7 +306,7 @@ Source: [eigenlayer.xyz](https://docs.eigenlayer.xyz/) ### Obol Splits β€” Factory Contracts -Obol's reward-splitting infrastructure for distributed validators. Factory contracts deploy per-cluster split instances. +Obol's Ethereum Validator Manager and reward splitting contracts. Factory contract pattern. Used with splits.org splitter smart contracts and gnosis SAFEs. #### Obol Validator Manager Factory | Chain | Address | Status | @@ -315,6 +318,7 @@ Obol's reward-splitting infrastructure for distributed validators. Factory contr #### Obol Lido Split Factory | Chain | Address | Status | |-------|---------|--------| +| Mainnet | `0xa9d94139a310150ca1163b5e23f3e1dbb7d9e2a6` | βœ… Verified | | Hoodi | `0xb633CD420aF83E8A5172e299104842b63dd97ab7` | βœ… Verified | #### Optimistic Withdrawal Recipient (OWR) Factory @@ -326,6 +330,38 @@ Obol's reward-splitting infrastructure for distributed validators. Factory contr Source: [docs.obol.org/learn/readme/obol-splits#deployments](https://docs.obol.org/learn/readme/obol-splits#deployments) +### Splits.org (0xSplits) β€” Payment Splitting + +Onchain payment splitting protocol. Obol uses Splits under the hood for validator reward distribution. V2 contracts are deployed via CreateX (same address on all chains). Prefer V2. + +#### V1 β€” SplitMain +| Network | Address | Status | +|---------|---------|--------| +| All chains | `0x2ed6c4B5dA6378c7897AC67Ba9e43102Feb694EE` | βœ… Verified | + +Verified on: Mainnet, Optimism, Arbitrum, Polygon, Base, Gnosis, BSC (identical address via CREATE2). + +#### V2 β€” SplitsWarehouse (ERC-6909 token wrapper) +| Network | Address | Status | +|---------|---------|--------| +| All chains | `0x8fb66F38cF86A3d5e8768f8F1754A24A6c661Fb8` | βœ… Verified | + +Holds tokens on behalf of recipients in the pull-flow model. Replaces SplitMain as the central fund-holding contract. + +#### V2 β€” PullSplitFactory (recipients withdraw from warehouse) +| Version | Address | Status | +|---------|---------|--------| +| V2.2 | `0x6B9118074aB15142d7524E8c4ea8f62A3Bdb98f1` | βœ… Verified | + +#### V2 β€” PushSplitFactory (funds pushed to recipients on distribute) +| Version | Address | Status | +|---------|---------|--------| +| V2.2 | `0x8E8eB0cC6AE34A38B67D5Cf91ACa38f60bc3Ecf4` | βœ… Verified | + +All V2 addresses verified identical on: Mainnet, Arbitrum, Optimism, Base (CreateX deterministic deployment). + +Source: [github.com/0xSplits/splits-contracts-monorepo](https://github.com/0xSplits/splits-contracts-monorepo/tree/main/packages/splits-v2/deployments) + ### Chainlink CCIP Router (v1.2.0) Cross-chain messaging. Call `typeAndVersion()` to confirm β€” returns "Router 1.2.0". @@ -552,6 +588,7 @@ cast code 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --rpc-url http://erpc.erpc. - **1inch:** https://docs.1inch.io/docs/aggregation-protocol/introduction - **EigenLayer:** https://docs.eigenlayer.xyz/ - **Obol Splits:** https://docs.obol.org/learn/readme/obol-splits#deployments +- **Splits.org (0xSplits):** https://github.com/0xSplits/splits-contracts-monorepo/tree/main/packages/splits-v2/deployments - **Across:** https://docs.across.to/reference/contract-addresses - **Chainlink CCIP:** https://docs.chain.link/ccip/directory/mainnet - **Yearn V3:** https://docs.yearn.fi/developers/addresses/v3-contracts @@ -561,7 +598,7 @@ cast code 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --rpc-url http://erpc.erpc. ## Multi-Chain Notes -- **CREATE2 deployments** (same address cross-chain): Uniswap V3, Safe, Seaport, ERC-4337 EntryPoint, ERC-8004, Permit2, 1inch v6, Yearn V3, Arachnid Deployer +- **CREATE2 deployments** (same address cross-chain): Uniswap V3, Safe, Seaport, ERC-4337 EntryPoint, ERC-8004, Permit2, 1inch v6, Yearn V3, Splits.org (V1 + V2), Arachnid Deployer - **Different addresses per chain:** USDC, USDT, DAI, WETH, wstETH, **Uniswap V4**, Across SpokePool, Chainlink CCIP Router β€” always check per-chain - **Native vs Bridged USDC:** Some chains have both! Use native. From 6628a15dfff2c1b6b5beaedbd5f6b6ef04bc4323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Sun, 22 Feb 2026 18:26:10 +0000 Subject: [PATCH 10/14] type two transactions. need a better wallet impl --- .../embed/skills/ethereum-wallet/SKILL.md | 18 ++- .../skills/ethereum-wallet/scripts/signer.py | 120 ++++++++++++------ 2 files changed, 93 insertions(+), 45 deletions(-) diff --git a/internal/embed/skills/ethereum-wallet/SKILL.md b/internal/embed/skills/ethereum-wallet/SKILL.md index 535b1d8..688969d 100644 --- a/internal/embed/skills/ethereum-wallet/SKILL.md +++ b/internal/embed/skills/ethereum-wallet/SKILL.md @@ -56,15 +56,27 @@ python3 scripts/signer.py sign-typed 0xYourAddress '{"types":{...},"primaryType" | `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-tx` | `--from --to [--value] [--data] [--gas] [--nonce] [--max-fee] [--priority-fee] [--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 | +| `send-tx` | `--from --to [--value] [--data] [--max-fee] [--priority-fee] [--network]` | Sign AND broadcast via eRPC | + +## Transaction Types + +Transactions default to **EIP-1559 (type 2)** with `maxFeePerGas` and `maxPriorityFeePerGas`. +If these are omitted, they are auto-derived from the network's base fee and priority fee. + +Use `--gas-price` to force a **legacy (type 0)** transaction instead: + +```bash +python3 scripts/signer.py send-tx --gas-price 0x3B9ACA00 \ + --from 0xYourAddress --to 0xRecipient --value 0xDE0B6B3A7640000 +``` ## Transaction Submission Flow `send-tx` does the following: -1. Fetches nonce, gas price, chain ID from eRPC (unless provided) +1. Fetches nonce, EIP-1559 gas params, 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) diff --git a/internal/embed/skills/ethereum-wallet/scripts/signer.py b/internal/embed/skills/ethereum-wallet/scripts/signer.py index 2071d20..9fba4d0 100644 --- a/internal/embed/skills/ethereum-wallet/scripts/signer.py +++ b/internal/embed/skills/ethereum-wallet/scripts/signer.py @@ -125,66 +125,79 @@ def cmd_sign_typed(address, typed_data_str): 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) +def autofill_tx(tx, network): + """Auto-fill missing transaction fields from eRPC. - # Auto-fill missing fields from eRPC + Builds EIP-1559 (type 2) transactions by default. Falls back to legacy + (type 0) only when --gas-price is explicitly provided. + """ 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) + + # EIP-1559 (type 2) vs legacy (type 0) gas pricing. + # Use type 2 unless the caller explicitly set gasPrice. + if "gasPrice" in tx: + # Legacy mode β€” caller explicitly requested it via --gas-price + pass + else: + # EIP-1559: fetch maxPriorityFeePerGas and derive maxFeePerGas + if "maxPriorityFeePerGas" not in tx: + tx["maxPriorityFeePerGas"] = erpc_rpc("eth_maxPriorityFeePerGas", [], network) + if "maxFeePerGas" not in tx: + # maxFeePerGas = 2 * baseFee + maxPriorityFeePerGas + block = erpc_rpc("eth_getBlockByNumber", ["latest", False], network) + if block and "baseFeePerGas" in block: + base_fee = int(block["baseFeePerGas"], 16) + priority = int(tx["maxPriorityFeePerGas"], 16) + max_fee = 2 * base_fee + priority + tx["maxFeePerGas"] = hex(max_fee) + else: + # Chain doesn't report baseFee β€” fall back to legacy + del tx["maxPriorityFeePerGas"] + tx["gasPrice"] = erpc_rpc("eth_gasPrice", [], network) + # Set explicit type for EIP-1559 + if "maxFeePerGas" in tx: + tx["type"] = "0x2" + 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]) + return tx + + +def extract_signed_tx(signed): + """Extract the raw signed tx hex from eth_signTransaction response.""" 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)) + return signed + if isinstance(signed, dict) and "raw" in signed: + return signed["raw"] + print("Error: unexpected eth_signTransaction response: %s" % json.dumps(signed), file=sys.stderr) + sys.exit(1) + + +def cmd_sign_tx(args): + """Sign a transaction with eth_signTransaction. Returns raw signed tx hex.""" + tx, network = build_tx_from_args(args) + tx = autofill_tx(tx, network) + + signed = web3signer_rpc("eth_signTransaction", [tx]) + print(extract_signed_tx(signed)) def cmd_send_tx(args): """Sign and broadcast a transaction via eRPC.""" tx, network = build_tx_from_args(args) + tx = autofill_tx(tx, network) - # 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) + raw_tx = extract_signed_tx(signed) - # 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) @@ -200,7 +213,11 @@ def cmd_send_tx(args): # --------------------------------------------------------------------------- def build_tx_from_args(args): - """Parse --from, --to, --value, --data, --gas, --nonce, --network from args list.""" + """Parse transaction flags from args list. + + EIP-1559 (type 2) flags: --max-fee, --priority-fee + Legacy (type 0) flag: --gas-price (forces legacy mode) + """ tx = {} network = ERPC_NETWORK i = 0 @@ -223,6 +240,15 @@ def build_tx_from_args(args): elif args[i] == "--nonce" and i + 1 < len(args): tx["nonce"] = args[i + 1] i += 2 + elif args[i] == "--max-fee" and i + 1 < len(args): + tx["maxFeePerGas"] = args[i + 1] + i += 2 + elif args[i] == "--priority-fee" and i + 1 < len(args): + tx["maxPriorityFeePerGas"] = args[i + 1] + i += 2 + elif args[i] == "--gas-price" and i + 1 < len(args): + tx["gasPrice"] = args[i + 1] + i += 2 elif args[i] == "--network" and i + 1 < len(args): network = args[i + 1] i += 2 @@ -244,12 +270,22 @@ def usage(): accounts List signing addresses health Check Web3Signer health sign
Sign arbitrary data (eth_sign) - sign-tx --from --to [--value ] [--data ] [--gas ] [--nonce ] [--network ] + sign-tx --from --to [--value ] [--data ] [options] Sign a transaction (returns raw signed tx) - send-tx --from --to [--value ] [--data ] [--network ] + send-tx --from --to [--value ] [--data ] [options] Sign and broadcast a transaction sign-typed
Sign EIP-712 typed data +Transaction options: + --gas Gas limit (auto-estimated if omitted) + --nonce Nonce (auto-fetched if omitted) + --network Target network (default: mainnet) + --max-fee EIP-1559 maxFeePerGas (auto-derived if omitted) + --priority-fee EIP-1559 maxPriorityFeePerGas (auto-fetched if omitted) + --gas-price Force legacy (type 0) tx with this gasPrice + +Transactions default to EIP-1559 (type 2). Use --gas-price to force legacy. + Environment: WEB3SIGNER_URL Web3Signer URL (default: http://web3signer:9000) ERPC_URL eRPC base URL (default: http://erpc.erpc.svc.cluster.local:4000/rpc) From ffdce402c448d0211883954a6b9948b7110e3bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Sun, 22 Feb 2026 19:14:50 +0000 Subject: [PATCH 11/14] Sort local build of the frontend --- justfile | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/justfile b/justfile index f1584f8..59f257a 100644 --- a/justfile +++ b/justfile @@ -34,3 +34,48 @@ up: down: obol cluster down obol cluster purge + +# Path to the frontend repo (override with FRONTEND_DIR=../path just dev-frontend) +frontend_dir := env("FRONTEND_DIR", justfile_directory() / "../obol-stack-front-end") +dev_image := "obolnetwork/obol-stack-front-end:dev" + +# Build frontend from local source, import into k3d, and restart the pod +dev-frontend: + #!/usr/bin/env bash + set -e + CLUSTER_ID=$(cat .workspace/config/.stack-id 2>/dev/null || cat ~/.config/obol/.stack-id) + echo "β†’ Building {{ dev_image }} from {{ frontend_dir }}" + docker build -t {{ dev_image }} {{ frontend_dir }} + echo "β†’ Importing image into k3d cluster obol-stack-${CLUSTER_ID}" + k3d image import {{ dev_image }} -c "obol-stack-${CLUSTER_ID}" + echo "β†’ Restarting frontend deployment" + obol kubectl set image deployment/obol-frontend-obol-app \ + obol-app={{ dev_image }} -n obol-frontend + obol kubectl rollout restart deployment/obol-frontend-obol-app -n obol-frontend + obol kubectl rollout status deployment/obol-frontend-obol-app -n obol-frontend --timeout=120s + echo "βœ“ Frontend dev build live at http://obol.stack" + +# Rebuild and hot-swap frontend (skip docker cache for faster iteration) +dev-frontend-rebuild: + #!/usr/bin/env bash + set -e + CLUSTER_ID=$(cat .workspace/config/.stack-id 2>/dev/null || cat ~/.config/obol/.stack-id) + echo "β†’ Rebuilding {{ dev_image }} (no cache)" + docker build --no-cache -t {{ dev_image }} {{ frontend_dir }} + echo "β†’ Importing image into k3d cluster obol-stack-${CLUSTER_ID}" + k3d image import {{ dev_image }} -c "obol-stack-${CLUSTER_ID}" + echo "β†’ Restarting frontend deployment" + obol kubectl rollout restart deployment/obol-frontend-obol-app -n obol-frontend + obol kubectl rollout status deployment/obol-frontend-obol-app -n obol-frontend --timeout=120s + echo "βœ“ Frontend dev build live at http://obol.stack" + +# Reset frontend back to the released image +dev-frontend-reset: + #!/usr/bin/env bash + set -e + echo "β†’ Resetting frontend to released image" + obol kubectl set image deployment/obol-frontend-obol-app \ + obol-app=obolnetwork/obol-stack-front-end:v0.1.7 -n obol-frontend + obol kubectl rollout restart deployment/obol-frontend-obol-app -n obol-frontend + obol kubectl rollout status deployment/obol-frontend-obol-app -n obol-frontend --timeout=120s + echo "βœ“ Frontend reset to released image" From 40a7de5a358831375702404a36868d3d06066db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Sun, 22 Feb 2026 19:37:35 +0000 Subject: [PATCH 12/14] Simplify cmd --- cmd/obol/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/obol/main.go b/cmd/obol/main.go index a7a5299..74fbd91 100644 --- a/cmd/obol/main.go +++ b/cmd/obol/main.go @@ -61,7 +61,7 @@ COMMANDS: openclaw token Retrieve gateway token openclaw list List instances openclaw delete Remove instance and cluster resources - openclaw skills Manage skills (add skills from Clawhub) + openclaw skills Manage skills Model Providers: model setup Configure cloud AI provider in llmspy gateway From facb739e566e5f8b4d5e5c5b7e95006ca06d9d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Mon, 23 Feb 2026 11:32:43 +0000 Subject: [PATCH 13/14] Second iteration at a wallet --- .gitignore | 4 + go.mod | 3 +- go.sum | 2 - internal/embed/embed_skills_test.go | 6 +- internal/embed/skills/addresses/SKILL.md | 11 + .../skills/distributed-validators/SKILL.md | 9 +- .../embed/skills/ethereum-networks/SKILL.md | 136 +++--- .../skills/ethereum-networks/scripts/rpc.sh | 150 +++++++ internal/embed/skills/gas/SKILL.md | 16 +- .../SKILL.md | 43 +- .../references/web3signer-api.md | 0 .../scripts/signer.py | 0 .../scripts/tx-helper.sh | 119 +++++ internal/embed/skills/obol-stack/SKILL.md | 12 +- internal/embed/skills/security/SKILL.md | 6 + internal/embed/skills/testing/SKILL.md | 11 + internal/embed/skills/tools/SKILL.md | 33 +- internal/embed/skills/wallets/SKILL.md | 18 +- internal/openclaw/openclaw.go | 31 +- internal/openclaw/skills_injection_test.go | 4 +- internal/openclaw/web3signer.go | 414 ++++++++++++------ internal/openclaw/web3signer_test.go | 287 +++++------- plans/foundry-skill-enhancements.md | 66 +++ 23 files changed, 974 insertions(+), 407 deletions(-) create mode 100644 internal/embed/skills/ethereum-networks/scripts/rpc.sh rename internal/embed/skills/{ethereum-wallet => local-ethereum-wallet}/SKILL.md (75%) rename internal/embed/skills/{ethereum-wallet => local-ethereum-wallet}/references/web3signer-api.md (100%) rename internal/embed/skills/{ethereum-wallet => local-ethereum-wallet}/scripts/signer.py (100%) create mode 100644 internal/embed/skills/local-ethereum-wallet/scripts/tx-helper.sh create mode 100644 plans/foundry-skill-enhancements.md diff --git a/.gitignore b/.gitignore index fe4d1f1..2765785 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,7 @@ temp/ .direnv/ bin + +# Wallet files +**/password.txt +obol-agent diff --git a/go.mod b/go.mod index c3d593a..eb85a34 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,9 @@ 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 ) @@ -39,6 +37,7 @@ 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 c9bd698..c23a960 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,6 @@ 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 a2d47d4..4a051fe 100644 --- a/internal/embed/embed_skills_test.go +++ b/internal/embed/embed_skills_test.go @@ -13,7 +13,7 @@ func TestGetEmbeddedSkillNames(t *testing.T) { } // Core skills that must always be present - required := []string{"distributed-validators", "ethereum-networks", "local-wallet", "obol-stack"} + required := []string{"distributed-validators", "ethereum-networks", "local-ethereum-wallet", "obol-stack"} nameSet := make(map[string]bool, len(names)) for _, n := range names { nameSet[n] = true @@ -33,7 +33,7 @@ func TestCopySkills(t *testing.T) { } // Every skill must have a SKILL.md - skills := []string{"distributed-validators", "ethereum-networks", "local-wallet", "obol-stack"} + skills := []string{"distributed-validators", "ethereum-networks", "local-ethereum-wallet", "obol-stack"} for _, skill := range skills { skillMD := filepath.Join(destDir, skill, "SKILL.md") info, err := os.Stat(skillMD) @@ -72,7 +72,7 @@ func TestCopySkillsSkipsExisting(t *testing.T) { destDir := t.TempDir() // Pre-create a skill directory with custom content - customDir := filepath.Join(destDir, "local-wallet") + customDir := filepath.Join(destDir, "local-ethereum-wallet") if err := os.MkdirAll(customDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } diff --git a/internal/embed/skills/addresses/SKILL.md b/internal/embed/skills/addresses/SKILL.md index f2df715..f19ab84 100644 --- a/internal/embed/skills/addresses/SKILL.md +++ b/internal/embed/skills/addresses/SKILL.md @@ -9,6 +9,17 @@ description: Verified contract addresses for major Ethereum protocols across mai **Last Verified:** February 16, 2026 (all addresses verified onchain via `eth_getCode` + `eth_call`) +**Verify any address at runtime:** +```bash +# Check contract exists (returns bytecode, or 0x if EOA) +sh scripts/rpc.sh code 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 + +# Read contract identity +sh scripts/rpc.sh call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 "symbol()(string)" +``` + +(`scripts/rpc.sh` is from the `ethereum-networks` skill) + --- ## Stablecoins diff --git a/internal/embed/skills/distributed-validators/SKILL.md b/internal/embed/skills/distributed-validators/SKILL.md index 444140b..262e08e 100644 --- a/internal/embed/skills/distributed-validators/SKILL.md +++ b/internal/embed/skills/distributed-validators/SKILL.md @@ -148,8 +148,13 @@ Exit broadcasts automatically once enough operators have signed (threshold reach - **Read-only** β€” creating clusters, running DKG, and submitting exits require authenticated endpoints - Exit status endpoints (`/v1/exp/`) are experimental β€” pagination is 1-indexed - If timeouts occur, check `GET /v1/_health` first -- **Shell is `sh`, not `bash`** β€” do not use bashisms like `${var//pattern}`, `[[ ]]`, or arrays. Use POSIX-compatible syntax only -- **Python stdlib only** β€” only the Python 3.11 standard library is available. No third-party packages +- **Shell is `sh`, not `bash`** β€” do not use bashisms - **Always check for null/missing data** β€” API responses may return errors or empty results. Always check before accessing nested fields See `references/api-examples.md` for response shapes and field reference. + +## See Also + +- `ethereum-networks` β€” blockchain RPC queries (validator balances, beacon chain data) +- `obol-stack` β€” Kubernetes cluster monitoring (pod status, logs) +- `local-ethereum-wallet` β€” transaction signing for exit operations diff --git a/internal/embed/skills/ethereum-networks/SKILL.md b/internal/embed/skills/ethereum-networks/SKILL.md index 9f28b5d..27a952e 100644 --- a/internal/embed/skills/ethereum-networks/SKILL.md +++ b/internal/embed/skills/ethereum-networks/SKILL.md @@ -1,7 +1,7 @@ --- name: ethereum-networks description: "Query Ethereum networks through the local RPC gateway. Use when asked about blocks, balances, transactions, gas prices, token balances, or any eth_* JSON-RPC method. All queries are read-only and routed through the in-cluster eRPC load balancer." -metadata: { "openclaw": { "emoji": "⛓️", "requires": { "bins": ["curl", "python3"] } } } +metadata: { "openclaw": { "emoji": "⛓️", "requires": { "bins": ["cast"] } } } --- # Ethereum Networks @@ -18,7 +18,7 @@ Query Ethereum blockchain data through the local eRPC gateway. Supports any JSON ## When NOT to Use -- Sending transactions, signing, or deploying contracts β€” use `ethereum-wallet` +- Sending transactions, signing, or deploying contracts β€” use `local-ethereum-wallet` - Validator monitoring β€” use `distributed-validators` - Kubernetes pod diagnostics β€” use `obol-stack` @@ -38,87 +38,111 @@ To see which networks are connected: curl -s http://erpc.erpc.svc.cluster.local:4000/ | python3 -m json.tool ``` -## Quick Start +## Quick Start (cast) + +Prefer `rpc.sh` (uses Foundry's `cast`) β€” it handles ABI decoding, unit conversion, and ENS natively: ```bash -# Block number -python3 scripts/rpc.py eth_blockNumber +# ETH balance (in ether) +sh scripts/rpc.sh balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 -# On a different network -python3 scripts/rpc.py --network hoodi eth_blockNumber +# Block details +sh scripts/rpc.sh block latest -# ETH balance -python3 scripts/rpc.py eth_getBalance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +# Transaction lookup +sh scripts/rpc.sh tx 0x + +# Transaction receipt +sh scripts/rpc.sh receipt 0x + +# Contract read with ABI decoding +sh scripts/rpc.sh call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 "totalSupply()(uint256)" + +# Gas price and base fee +sh scripts/rpc.sh gas-price +sh scripts/rpc.sh base-fee + +# Nonce +sh scripts/rpc.sh nonce 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 -# Gas price -python3 scripts/rpc.py eth_gasPrice +# Different network +sh scripts/rpc.sh --network hoodi block latest -# Chain ID -python3 scripts/rpc.py eth_chainId +# ENS resolution +sh scripts/rpc.sh ens vitalik.eth -# Read a contract (e.g. ERC-20 totalSupply) -python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x18160ddd +# Unit conversion +sh scripts/rpc.sh from-wei 1000000000000000000 +sh scripts/rpc.sh to-wei 1.5 + +# Decode a function selector +sh scripts/rpc.sh 4byte 0xa9059cbb + +# Decode ABI-encoded return data +sh scripts/rpc.sh abi-decode "balanceOf(address)(uint256)" 0x00000000000000000000000000000000000000000000000000000000000f4240 + +# Raw JSON-RPC method +sh scripts/rpc.sh raw eth_blockNumber ``` -## Supported Methods - -| Method | Params | Returns | -|--------|--------|---------| -| `eth_blockNumber` | none | Latest block number | -| `eth_getBalance` | `address [block]` | Balance (auto-converted to ETH) | -| `eth_gasPrice` | none | Gas price (auto-converted to Gwei) | -| `eth_chainId` | none | Chain ID | -| `eth_getBlockByNumber` | `blockNum includeTxs` | Block data | -| `eth_getTransactionByHash` | `txHash` | Transaction details | -| `eth_getTransactionReceipt` | `txHash` | Receipt with logs and status | -| `eth_call` | `to data [block]` | Contract read result | -| `eth_estimateGas` | `to data [from] [value]` | Gas estimate | -| `eth_getLogs` | `fromBlock toBlock [address] [topic0]` | Event logs | +## Commands + +| Command | Params | Description | +|---------|--------|-------------| +| `balance` | `address` | ETH balance in ether | +| `block` | `[number\|latest]` | Block details | +| `tx` | `hash` | Transaction details | +| `receipt` | `hash` | Transaction receipt with logs | +| `call` | `to sig [args...]` | Contract read with ABI decoding | +| `estimate` | `to sig [args...]` | Gas estimate for a call | +| `chain-id` | none | Chain ID | +| `gas-price` | none | Current gas price in wei | +| `base-fee` | none | Current base fee | +| `nonce` | `address` | Transaction count | +| `code` | `address` | Contract bytecode | +| `ens` | `name` | Resolve ENS name to address | +| `from-wei` | `value [unit]` | Convert from wei | +| `to-wei` | `value [unit]` | Convert to wei | +| `4byte` | `selector` | Decode 4-byte function selector | +| `abi-decode` | `sig data` | Decode ABI-encoded data | +| `logs` | `address [topic0] [--from-block N]` | Query event logs | +| `raw` | `method [params...]` | Raw JSON-RPC call | ## Token Queries -Use `eth_call` with the token contract address and function selector: +With `cast`, contract reads use human-readable function signatures instead of raw selectors: ```bash -# balanceOf β€” pad address to 32 bytes -python3 scripts/rpc.py eth_call \ - 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ - 0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +# ERC-20 balance (auto-decoded to uint256) +sh scripts/rpc.sh call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ + "balanceOf(address)(uint256)" 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 -# name() -python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x06fdde03 +# Token name +sh scripts/rpc.sh call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 "name()(string)" -# decimals() -python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x313ce567 +# Token decimals +sh scripts/rpc.sh call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 "decimals()(uint8)" + +# Token symbol +sh scripts/rpc.sh call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 "symbol()(string)" ``` See `references/erc20-methods.md` for the full selector reference and `references/common-contracts.md` for well-known addresses. -## Direct curl +## Fallback: Python rpc.py -When the helper script doesn't cover a method: +The `rpc.py` script is still available as a fallback if `cast` is not present: ```bash -curl -s -X POST http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ - | python3 -c " -import sys, json -r = json.load(sys.stdin) -if r.get('result') is not None: - print(int(r['result'], 16)) -elif 'error' in r: - print('Error:', r['error'].get('message', r['error'])) -else: - print(r) -" +python3 scripts/rpc.py eth_blockNumber +python3 scripts/rpc.py eth_getBalance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +python3 scripts/rpc.py --network hoodi eth_chainId ``` ## Constraints - **Read-only** β€” no private keys, no signing, no state changes - **Local routing** β€” always route through eRPC at `http://erpc.erpc.svc.cluster.local:4000/rpc/`, never call external RPC providers -- **Hex encoding** β€” JSON-RPC uses hex; the helper script auto-converts common cases - **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 -- **Always check for null results** β€” RPC methods like `eth_getTransactionByHash` return `null` for unknown hashes. Always check `if result is not None` before accessing fields +- **`cast` preferred** β€” use `rpc.sh` (Foundry cast) for all queries. Fall back to `rpc.py` (Python stdlib) only if cast is unavailable +- **Always check for null results** β€” RPC methods like `eth_getTransactionByHash` return `null` for unknown hashes diff --git a/internal/embed/skills/ethereum-networks/scripts/rpc.sh b/internal/embed/skills/ethereum-networks/scripts/rpc.sh new file mode 100644 index 0000000..98394ed --- /dev/null +++ b/internal/embed/skills/ethereum-networks/scripts/rpc.sh @@ -0,0 +1,150 @@ +#!/bin/sh +# rpc.sh β€” Query Ethereum networks via Foundry's cast CLI. +# Uses the in-cluster eRPC gateway. Prefer this over rpc.py when cast is available. +# +# Usage: sh scripts/rpc.sh [--network ] [args...] +# +# Environment: +# ERPC_URL Base URL for eRPC gateway (default: http://erpc.erpc.svc.cluster.local:4000/rpc) +# ERPC_NETWORK Default network (default: mainnet) +set -eu + +ERPC_BASE="${ERPC_URL:-http://erpc.erpc.svc.cluster.local:4000/rpc}" +NETWORK="${ERPC_NETWORK:-mainnet}" + +# Parse --network flag +while [ $# -gt 0 ]; do + case "$1" in + --network) NETWORK="$2"; shift 2 ;; + --network=*) NETWORK="${1#--network=}"; shift ;; + *) break ;; + esac +done + +RPC_URL="${ERPC_BASE}/${NETWORK}" + +if [ $# -eq 0 ]; then + echo "Usage: sh scripts/rpc.sh [--network ] [args...]" + echo "" + echo "Commands:" + echo " balance
ETH balance in ether" + echo " block [number|latest] Block details" + echo " tx Transaction details" + echo " receipt Transaction receipt" + echo " call [args...] Contract read (with ABI decoding)" + echo " estimate [args...] Gas estimate for a call" + echo " chain-id Chain ID" + echo " gas-price Current gas price" + echo " base-fee Current base fee" + echo " nonce
Transaction count" + echo " code
Contract bytecode" + echo " ens Resolve ENS name" + echo " from-wei [unit] Convert from wei" + echo " to-wei [unit] Convert to wei" + echo " 4byte Decode function selector" + echo " abi-decode Decode ABI-encoded data" + echo " logs
[topic0] [--from-block N] [--to-block N]" + echo " raw [params...] Raw JSON-RPC method" + exit 0 +fi + +CMD="$1"; shift + +case "$CMD" in + balance) + [ $# -lt 1 ] && { echo "Usage: balance
"; exit 1; } + cast balance "$1" --ether --rpc-url "$RPC_URL" + ;; + + block) + BLOCK="${1:-latest}" + cast block "$BLOCK" --rpc-url "$RPC_URL" + ;; + + tx) + [ $# -lt 1 ] && { echo "Usage: tx "; exit 1; } + cast tx "$1" --rpc-url "$RPC_URL" + ;; + + receipt) + [ $# -lt 1 ] && { echo "Usage: receipt "; exit 1; } + cast receipt "$1" --rpc-url "$RPC_URL" + ;; + + call) + [ $# -lt 2 ] && { echo "Usage: call [args...]"; exit 1; } + TO="$1"; SIG="$2"; shift 2 + cast call "$TO" "$SIG" "$@" --rpc-url "$RPC_URL" + ;; + + estimate) + [ $# -lt 2 ] && { echo "Usage: estimate [args...]"; exit 1; } + TO="$1"; SIG="$2"; shift 2 + cast estimate "$TO" "$SIG" "$@" --rpc-url "$RPC_URL" + ;; + + chain-id) + cast chain-id --rpc-url "$RPC_URL" + ;; + + gas-price) + cast gas-price --rpc-url "$RPC_URL" + ;; + + base-fee) + cast base-fee --rpc-url "$RPC_URL" + ;; + + nonce) + [ $# -lt 1 ] && { echo "Usage: nonce
"; exit 1; } + cast nonce "$1" --rpc-url "$RPC_URL" + ;; + + code) + [ $# -lt 1 ] && { echo "Usage: code
"; exit 1; } + cast code "$1" --rpc-url "$RPC_URL" + ;; + + ens) + [ $# -lt 1 ] && { echo "Usage: ens "; exit 1; } + cast resolve-name "$1" --rpc-url "$RPC_URL" + ;; + + from-wei) + [ $# -lt 1 ] && { echo "Usage: from-wei [unit]"; exit 1; } + UNIT="${2:-ether}" + cast from-wei "$1" "$UNIT" + ;; + + to-wei) + [ $# -lt 1 ] && { echo "Usage: to-wei [unit]"; exit 1; } + UNIT="${2:-ether}" + cast to-wei "$1" "$UNIT" + ;; + + 4byte) + [ $# -lt 1 ] && { echo "Usage: 4byte "; exit 1; } + cast 4byte "$1" + ;; + + abi-decode) + [ $# -lt 2 ] && { echo "Usage: abi-decode "; exit 1; } + cast abi-decode "$1" "$2" + ;; + + logs) + [ $# -lt 1 ] && { echo "Usage: logs
[topic0] [--from-block N] [--to-block N]"; exit 1; } + cast logs "$@" --rpc-url "$RPC_URL" + ;; + + raw) + [ $# -lt 1 ] && { echo "Usage: raw [params...]"; exit 1; } + cast rpc "$@" --rpc-url "$RPC_URL" + ;; + + *) + echo "Unknown command: $CMD" + echo "Run without arguments to see usage." + exit 1 + ;; +esac diff --git a/internal/embed/skills/gas/SKILL.md b/internal/embed/skills/gas/SKILL.md index 281537d..b5fbced 100644 --- a/internal/embed/skills/gas/SKILL.md +++ b/internal/embed/skills/gas/SKILL.md @@ -87,14 +87,26 @@ Spikes (10-50 gwei) happen during major events but last minutes to hours, not da ## Checking Gas Programmatically +`cast` is available inside OpenClaw pods. Use the `ethereum-networks` skill for convenience: + ```bash -# Foundry cast (use local eRPC if running in Obol Stack) +# Via rpc.sh wrapper (simplest) +sh scripts/rpc.sh gas-price +sh scripts/rpc.sh base-fee + +# Via cast directly cast gas-price --rpc-url http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet cast base-fee --rpc-url http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet cast blob-basefee --rpc-url http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet -# Fallback public RPC: https://eth.llamarpc.com + +# Estimate gas for a specific call +cast estimate 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ + "transfer(address,uint256)" 0xRecipient 1000000 \ + --rpc-url http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet ``` +Note: `scripts/rpc.sh` is from the `ethereum-networks` skill. Copy it or reference it directly. + ## When to Use Mainnet vs L2 **Use mainnet when:** Maximum security matters (>$10M TVL), composing with mainnet-only liquidity, deploying governance/infrastructure contracts, NFTs with cultural value. diff --git a/internal/embed/skills/ethereum-wallet/SKILL.md b/internal/embed/skills/local-ethereum-wallet/SKILL.md similarity index 75% rename from internal/embed/skills/ethereum-wallet/SKILL.md rename to internal/embed/skills/local-ethereum-wallet/SKILL.md index 688969d..703bf1a 100644 --- a/internal/embed/skills/ethereum-wallet/SKILL.md +++ b/internal/embed/skills/local-ethereum-wallet/SKILL.md @@ -1,5 +1,5 @@ --- -name: ethereum-wallet +name: local-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"] } } } --- @@ -104,15 +104,48 @@ All `--value` amounts are hex-encoded wei, matching the JSON-RPC standard: | 0.01 ETH | `0x2386F26FC10000` | | 1 Gwei | `0x3B9ACA00` | -The script does NOT auto-convert from ETH decimal notation. +Use `tx-helper.sh` for unit conversion instead of manual hex: + +```bash +sh scripts/tx-helper.sh to-wei 1 # β†’ 1000000000000000000 +sh scripts/tx-helper.sh to-wei 0.1 # β†’ 100000000000000000 +sh scripts/tx-helper.sh to-hex 1000000 # β†’ 0xf4240 +``` + +## Transaction Helpers (cast) + +Use `tx-helper.sh` for pre-signing operations β€” gas estimation, ABI encoding, calldata construction: + +```bash +# Estimate gas for a contract call +sh scripts/tx-helper.sh estimate 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ + "transfer(address,uint256)" 0xRecipient 1000000 + +# Estimate gas for a simple ETH transfer +sh scripts/tx-helper.sh estimate-simple 0xRecipient 1000000000000000000 + +# Encode function calldata (for use with --data flag in signer.py) +sh scripts/tx-helper.sh calldata "transfer(address,uint256)" 0xRecipient 1000000 + +# Get the 4-byte selector for a function +sh scripts/tx-helper.sh sig "transfer(address,uint256)" + +# Decode a raw signed transaction +sh scripts/tx-helper.sh decode-tx 0x02f8... + +# Fetch contract interface/ABI +sh scripts/tx-helper.sh interface 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 + +# Checksum an address +sh scripts/tx-helper.sh checksum 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 +``` ## 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 +- **Shell is `sh`, not `bash`** β€” do not use bashisms - **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 +- **Signing via signer.py** β€” use `signer.py` for all signing/sending operations. Use `tx-helper.sh` only for pre-signing utilities (gas estimation, encoding, conversion) - **Confirm before sending** β€” always show the user what will be signed before executing `send-tx` ## Environment Variables diff --git a/internal/embed/skills/ethereum-wallet/references/web3signer-api.md b/internal/embed/skills/local-ethereum-wallet/references/web3signer-api.md similarity index 100% rename from internal/embed/skills/ethereum-wallet/references/web3signer-api.md rename to internal/embed/skills/local-ethereum-wallet/references/web3signer-api.md diff --git a/internal/embed/skills/ethereum-wallet/scripts/signer.py b/internal/embed/skills/local-ethereum-wallet/scripts/signer.py similarity index 100% rename from internal/embed/skills/ethereum-wallet/scripts/signer.py rename to internal/embed/skills/local-ethereum-wallet/scripts/signer.py diff --git a/internal/embed/skills/local-ethereum-wallet/scripts/tx-helper.sh b/internal/embed/skills/local-ethereum-wallet/scripts/tx-helper.sh new file mode 100644 index 0000000..6326f88 --- /dev/null +++ b/internal/embed/skills/local-ethereum-wallet/scripts/tx-helper.sh @@ -0,0 +1,119 @@ +#!/bin/sh +# tx-helper.sh β€” Pre-signing transaction utilities via Foundry's cast. +# Handles gas estimation, ABI encoding, calldata construction, and unit conversion. +# Signing is still done via Web3Signer (signer.py) β€” this script prepares tx data. +# +# Usage: sh scripts/tx-helper.sh [--network ] [args...] +# +# Environment: +# ERPC_URL Base URL for eRPC gateway (default: http://erpc.erpc.svc.cluster.local:4000/rpc) +# ERPC_NETWORK Default network (default: mainnet) +set -eu + +ERPC_BASE="${ERPC_URL:-http://erpc.erpc.svc.cluster.local:4000/rpc}" +NETWORK="${ERPC_NETWORK:-mainnet}" + +# Parse --network flag +while [ $# -gt 0 ]; do + case "$1" in + --network) NETWORK="$2"; shift 2 ;; + --network=*) NETWORK="${1#--network=}"; shift ;; + *) break ;; + esac +done + +RPC_URL="${ERPC_BASE}/${NETWORK}" + +if [ $# -eq 0 ]; then + echo "Usage: sh scripts/tx-helper.sh [--network ] [args...]" + echo "" + echo "Commands:" + echo " estimate [args...] Gas estimate for a contract call" + echo " estimate-simple [value] Gas estimate for a simple ETH transfer" + echo " calldata [args...] Encode function calldata" + echo " decode-tx Decode a raw signed transaction" + echo " interface
Fetch contract ABI (from Etherscan)" + echo " to-wei [unit] Convert to wei" + echo " from-wei [unit] Convert from wei" + echo " to-hex Decimal to hex" + echo " from-hex Hex to decimal" + echo " checksum
EIP-55 checksum an address" + echo " keccak Keccak256 hash" + echo " sig Get 4-byte function selector" + exit 0 +fi + +CMD="$1"; shift + +case "$CMD" in + estimate) + [ $# -lt 2 ] && { echo "Usage: estimate [args...]"; exit 1; } + TO="$1"; SIG="$2"; shift 2 + cast estimate "$TO" "$SIG" "$@" --rpc-url "$RPC_URL" + ;; + + estimate-simple) + [ $# -lt 1 ] && { echo "Usage: estimate-simple [value]"; exit 1; } + TO="$1" + VALUE="${2:-0}" + cast estimate "$TO" --value "$VALUE" --rpc-url "$RPC_URL" + ;; + + calldata) + [ $# -lt 1 ] && { echo "Usage: calldata [args...]"; exit 1; } + cast calldata "$@" + ;; + + decode-tx) + [ $# -lt 1 ] && { echo "Usage: decode-tx "; exit 1; } + cast decode-transaction "$1" + ;; + + interface) + [ $# -lt 1 ] && { echo "Usage: interface
"; exit 1; } + cast interface "$1" --rpc-url "$RPC_URL" + ;; + + to-wei) + [ $# -lt 1 ] && { echo "Usage: to-wei [unit]"; exit 1; } + UNIT="${2:-ether}" + cast to-wei "$1" "$UNIT" + ;; + + from-wei) + [ $# -lt 1 ] && { echo "Usage: from-wei [unit]"; exit 1; } + UNIT="${2:-ether}" + cast from-wei "$1" "$UNIT" + ;; + + to-hex) + [ $# -lt 1 ] && { echo "Usage: to-hex "; exit 1; } + cast to-hex "$1" + ;; + + from-hex) + [ $# -lt 1 ] && { echo "Usage: from-hex "; exit 1; } + cast to-dec "$1" + ;; + + checksum) + [ $# -lt 1 ] && { echo "Usage: checksum
"; exit 1; } + cast to-check-sum-address "$1" + ;; + + keccak) + [ $# -lt 1 ] && { echo "Usage: keccak "; exit 1; } + cast keccak "$1" + ;; + + sig) + [ $# -lt 1 ] && { echo "Usage: sig "; exit 1; } + cast sig "$1" + ;; + + *) + echo "Unknown command: $CMD" + echo "Run without arguments to see usage." + exit 1 + ;; +esac diff --git a/internal/embed/skills/obol-stack/SKILL.md b/internal/embed/skills/obol-stack/SKILL.md index 315848b..1919f62 100644 --- a/internal/embed/skills/obol-stack/SKILL.md +++ b/internal/embed/skills/obol-stack/SKILL.md @@ -93,6 +93,12 @@ Supported types for `describe`: pod, service, deployment, configmap, event, pvc, - **Read-only** β€” cannot create, modify, or delete resources - **Own namespace only** β€” cannot see other namespaces -- **No kubectl** β€” uses the Kubernetes API directly via curl -- **Shell is `sh`, not `bash`** β€” do not use bashisms like `${var//pattern}`, `[[ ]]`, or arrays. Use POSIX-compatible syntax only -- **Python stdlib only** β€” only the Python 3.11 standard library is available. No third-party packages +- **No kubectl** β€” uses the Kubernetes API directly via Python urllib +- **Shell is `sh`, not `bash`** β€” do not use bashisms +- **Python stdlib only** β€” `kube.py` uses Python 3.11 stdlib (no third-party packages) + +## See Also + +- `ethereum-networks` β€” blockchain RPC queries via eRPC +- `distributed-validators` β€” DVT cluster monitoring via Obol API +- `local-ethereum-wallet` β€” transaction signing and wallet operations diff --git a/internal/embed/skills/security/SKILL.md b/internal/embed/skills/security/SKILL.md index 5950c07..b166a1e 100644 --- a/internal/embed/skills/security/SKILL.md +++ b/internal/embed/skills/security/SKILL.md @@ -462,6 +462,12 @@ forge test --gas-report # Identify expensive functions - Arbitrary `delegatecall` or `selfdestruct` - Unprotected state-changing functions +## See Also + +- `testing` β€” fuzz testing, invariant testing, fork testing patterns +- `wallets` β€” key safety guardrails for AI agents +- `addresses` β€” verified contract addresses (avoid using wrong addresses) + ## Further Reading - **OpenZeppelin Contracts:** https://docs.openzeppelin.com/contracts β€” audited, battle-tested implementations diff --git a/internal/embed/skills/testing/SKILL.md b/internal/embed/skills/testing/SKILL.md index b889cd6..53046c1 100644 --- a/internal/embed/skills/testing/SKILL.md +++ b/internal/embed/skills/testing/SKILL.md @@ -377,3 +377,14 @@ forge test --fuzz-runs 1000 - [ ] Gas snapshots taken with `forge snapshot` to catch regressions - [ ] Static analysis with `slither .` β€” no high/medium findings unaddressed - [ ] All tests pass: `forge test -vvv` + +## Note on Tooling + +`forge`, `cast`, and `anvil` are available inside OpenClaw pods via the Foundry init container. All commands in this skill can be run directly. + +## See Also + +- `security` β€” vulnerability patterns and defensive code +- `tools` β€” full Foundry command reference and dev environment setup +- `gas` β€” current gas costs and estimation commands +- `ethereum-networks` β€” live RPC queries for fork testing targets diff --git a/internal/embed/skills/tools/SKILL.md b/internal/embed/skills/tools/SKILL.md index 3b0f5e8..8ee28a9 100644 --- a/internal/embed/skills/tools/SKILL.md +++ b/internal/embed/skills/tools/SKILL.md @@ -87,15 +87,20 @@ const response = await x402Fetch('https://api.example.com/data', { ## Essential Foundry cast Commands +`cast` is available inside OpenClaw pods via the Foundry init container. The local eRPC gateway is the default RPC: + ```bash -# Read contract +RPC="http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet" + +# Read contract (with ABI decoding) cast call 0xAddr "balanceOf(address)(uint256)" 0xWallet --rpc-url $RPC -# Send transaction -cast send 0xAddr "transfer(address,uint256)" 0xTo 1000000 --private-key $KEY --rpc-url $RPC +# Send transaction (via local-ethereum-wallet skill for signing) +# See local-ethereum-wallet skill for signing workflows -# Gas price +# Gas price / base fee cast gas-price --rpc-url $RPC +cast base-fee --rpc-url $RPC # Decode calldata cast 4byte-decode 0xa9059cbb... @@ -103,10 +108,22 @@ cast 4byte-decode 0xa9059cbb... # ENS resolution cast resolve-name vitalik.eth --rpc-url $RPC +# Encode calldata for contract interaction +cast calldata "transfer(address,uint256)" 0xRecipient 1000000 + +# Unit conversion +cast to-wei 1.5 ether # β†’ 1500000000000000000 +cast from-wei 1000000 gwei # β†’ 0.001 + +# Fetch contract interface +cast interface 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --rpc-url $RPC + # Fork mainnet locally anvil --fork-url $RPC ``` +For a full cast-based query tool, see the `ethereum-networks` skill (`scripts/rpc.sh`). + ## RPC Providers **Obol Stack (local, preferred):** @@ -162,3 +179,11 @@ anvil --fork-url http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet ``` **Primary testnet:** Sepolia (Chain ID: 11155111). Goerli and Rinkeby are deprecated. + +## Related Skills + +- `ethereum-networks` β€” cast-based blockchain queries (`scripts/rpc.sh`) +- `local-ethereum-wallet` β€” transaction signing + tx helpers (`scripts/tx-helper.sh`) +- `testing` β€” Foundry test patterns (forge test, fuzz, fork, invariant) +- `gas` β€” current gas costs and live estimation commands +- `addresses` β€” verified contract addresses across chains diff --git a/internal/embed/skills/wallets/SKILL.md b/internal/embed/skills/wallets/SKILL.md index 8f8499b..f51dcb9 100644 --- a/internal/embed/skills/wallets/SKILL.md +++ b/internal/embed/skills/wallets/SKILL.md @@ -97,16 +97,18 @@ git log --all -p | grep -iE 'private.?key|0x[a-fA-F0-9]{64}' ### Safe Patterns for AI Agents ```bash -# Load key from environment (NEVER hardcode) -cast send ... --private-key $DEPLOYER_PRIVATE_KEY +# Encrypted keystore (Obol Stack default β€” keys never stored in plaintext) +cast send ... --account obol-agent --keystore-dir /data/.foundry/keystores --password-file /secrets/keystore-password -# Or use encrypted keystore -cast send ... --keystore ~/.foundry/keystores/deployer --password-file .password +# Load key from environment (acceptable for testing, NEVER in production) +cast send ... --private-key $DEPLOYER_PRIVATE_KEY -# Or use hardware wallet +# Hardware wallet (best for high-value operations) cast send ... --ledger ``` +**Obol Stack uses encrypted V3 keystores by default.** Keys are generated during `obol agent init` via `cast wallet new`, stored encrypted on the PVC, and accessed via password file. The `local-ethereum-wallet` skill handles signing through Web3Signer (separate pod, keys never in the OpenClaw container). + **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. ## CRITICAL Guardrails for AI Agents @@ -161,6 +163,12 @@ async function sendSafely(wallet, to, value) { } ``` +## See Also + +- `local-ethereum-wallet` β€” sign and send transactions via the local signing service +- `security` β€” smart contract vulnerability patterns and defensive code +- `ethereum-networks` β€” blockchain queries and contract reads + ## Further Reading - **Safe docs:** https://docs.safe.global/ diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go index 8bd294a..ad52a25 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -217,19 +217,20 @@ func Onboard(cfg *config.Config, opts OnboardOptions) error { 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() + // Generate encrypted V3 keystore via Foundry cast and provision + // Web3Signer key config. The keystore is created before deployment + // so web3signer can load it on startup. + fmt.Println("\nGenerating wallet keystore via cast...") + keysDir := Web3SignerKeysPath(cfg, id) + wallet, err := GenerateKeystoreViaCast(keysDir) if err != nil { os.RemoveAll(deploymentDir) - return fmt.Errorf("failed to generate signing key: %w", err) + return fmt.Errorf("failed to generate keystore: %w", err) } - keysDir := Web3SignerKeysPath(cfg, id) keyLabel := fmt.Sprintf("obol-agent-%s", id) - if err := ProvisionKeyFiles(keysDir, signingKey, keyLabel); err != nil { + if err := ProvisionKeyFiles(keysDir, wallet, keyLabel); err != nil { os.RemoveAll(deploymentDir) - return fmt.Errorf("failed to provision signing key: %w", err) + return fmt.Errorf("failed to provision key config: %w", err) } // Write Web3Signer Helm values @@ -253,9 +254,9 @@ func Onboard(cfg *config.Config, opts OnboardOptions) error { } // Display wallet address and backup warning - fmt.Printf("\n Agent wallet address: %s\n", signingKey.Address) + fmt.Printf("\n Agent wallet address: %s\n", wallet.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(" cp -r %s/ ~/obol-wallet-backup-%s/\n", keysDir, 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") @@ -339,9 +340,9 @@ 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) + // Apply wallet-metadata ConfigMap (namespace now exists after helmfile sync). + // Read the signing address from the provisioned keystore files. + applyWalletMetadata(cfg, id) hostname := fmt.Sprintf("openclaw-%s.%s", id, defaultDomain) @@ -1336,6 +1337,10 @@ erpc: skills: enabled: false +# Foundry CLI tools (cast) available to skills via init container +foundry: + enabled: true + # Agent init Job (enable to bootstrap workspace on first deploy) initJob: enabled: false diff --git a/internal/openclaw/skills_injection_test.go b/internal/openclaw/skills_injection_test.go index 02e31b0..978317b 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-ethereum-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-ethereum-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 index 8b73d9b..801fa0b 100644 --- a/internal/openclaw/web3signer.go +++ b/internal/openclaw/web3signer.go @@ -2,18 +2,17 @@ package openclaw import ( "crypto/rand" - "encoding/hex" "encoding/json" "fmt" + "math/big" "os" "os/exec" "path/filepath" + "regexp" "strings" "time" "github.com/ObolNetwork/obol-stack/internal/config" - "github.com/decred/dcrd/dcrec/secp256k1/v4" - "golang.org/x/crypto/sha3" ) const ( @@ -21,63 +20,216 @@ const ( web3signerImageTag = "25.12.0" web3signerReleaseName = "web3signer" web3signerPort = 9000 + + keystoreAccountName = "obol-agent" + keystorePasswordLen = 32 + foundryImage = "ghcr.io/foundry-rs/foundry:stable" ) -// 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 +// WalletKey holds the address and file locations for a generated wallet. +type WalletKey struct { + Address string // 0x-prefixed, 42 chars + KeystoreFile string // absolute path to V3 JSON keystore on host + PasswordFile string // absolute path to password file on host } -// 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() +// GenerateKeystoreViaCast creates an encrypted V3 keystore using Foundry's +// cast wallet new command. It tries the host-installed cast binary first, +// then falls back to running cast inside a Docker container. +func GenerateKeystoreViaCast(keysDir string) (*WalletKey, error) { + if err := os.MkdirAll(keysDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create keys directory: %w", err) + } + + // Generate random password and write to file. cast reads the password + // from this file via --password-file (never passed on the command line). + password, err := generateRandomPassword(keystorePasswordLen) if err != nil { - return nil, fmt.Errorf("failed to generate secp256k1 key: %w", err) + return nil, fmt.Errorf("failed to generate password: %w", err) } - privBytes := privKey.Serialize() // 32 bytes - pubBytes := privKey.PubKey().SerializeUncompressed() // 65 bytes: 04 || x || y + passwordPath := filepath.Join(keysDir, "password.txt") + if err := os.WriteFile(passwordPath, []byte(password), 0600); err != nil { + return nil, fmt.Errorf("failed to write password file: %w", err) + } - // Ethereum address: keccak256(pubkey_without_prefix)[12:] - hash := sha3.NewLegacyKeccak256() - hash.Write(pubBytes[1:]) // skip 0x04 prefix - addrBytes := hash.Sum(nil)[12:] + // Try host cast binary + if castPath, err := exec.LookPath("cast"); err == nil { + address, err := runCastWalletNew(castPath, keysDir, passwordPath) + if err == nil { + keystoreFile := findKeystoreFile(keysDir) + return &WalletKey{ + Address: address, + KeystoreFile: keystoreFile, + PasswordFile: passwordPath, + }, nil + } + fmt.Printf(" Warning: host cast failed: %v\n", err) + } - // 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) + // Try Docker fallback + if dockerPath, err := exec.LookPath("docker"); err == nil { + address, err := runCastWalletNewDocker(dockerPath, keysDir, passwordPath) + if err == nil { + keystoreFile := findKeystoreFile(keysDir) + return &WalletKey{ + Address: address, + KeystoreFile: keystoreFile, + PasswordFile: passwordPath, + }, nil + } + fmt.Printf(" Warning: docker fallback failed: %v\n", err) } - return &Web3SignerKey{ - PrivateKeyHex: hex.EncodeToString(privBytes), - PublicKeyHex: "0x" + hex.EncodeToString(pubBytes), - Address: "0x" + hex.EncodeToString(addrBytes), - KeyID: hex.EncodeToString(idBytes), - }, nil + // Clean up password file on total failure + os.Remove(passwordPath) + return nil, fmt.Errorf("could not generate keystore.\n" + + " Install Foundry: curl -L https://foundry.paradigm.xyz | bash && foundryup\n" + + " Or install Docker: https://docs.docker.com/get-docker/") } -// 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) +// runCastWalletNew runs cast wallet new on the host to create a V3 keystore. +// The password is read from a file, never passed on the command line. +func runCastWalletNew(castBin, keysDir, passwordFile string) (string, error) { + cmd := exec.Command(castBin, "wallet", "new", keysDir, "--password-file", passwordFile) + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("cast wallet new: %w\n%s", err, output) } + return parseAddressFromOutput(string(output)) +} - // 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) +// runCastWalletNewDocker runs cast wallet new inside a Foundry Docker container. +// The password file is mounted into the container at /keys/password.txt. +func runCastWalletNewDocker(dockerBin, keysDir, passwordFile string) (string, error) { + absKeysDir, err := filepath.Abs(keysDir) + if err != nil { + return "", err + } + + // password.txt is inside keysDir, so the single volume mount covers both + containerPasswordPath := "/keys/" + filepath.Base(passwordFile) + cmd := exec.Command(dockerBin, "run", "--rm", + "-v", absKeysDir+":/keys", + foundryImage, + "cast", "wallet", "new", "/keys", "--password-file", containerPasswordPath, + ) + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("docker cast wallet new: %w\n%s", err, output) + } + return parseAddressFromOutput(string(output)) +} + +// parseAddressFromOutput extracts the 0x-prefixed Ethereum address from +// cast wallet new output. +var addressRe = regexp.MustCompile(`0x[0-9a-fA-F]{40}`) + +func parseAddressFromOutput(output string) (string, error) { + // Look for address in output lines (skip private key lines) + for _, line := range strings.Split(output, "\n") { + lower := strings.ToLower(line) + if strings.Contains(lower, "private") { + continue + } + if match := addressRe.FindString(line); match != "" { + return match, nil + } + } + return "", fmt.Errorf("could not find address in output:\n%s", output) +} + +// findKeystoreFile finds the V3 keystore file in the keys directory. +// cast wallet import creates files named after the account (e.g. "obol-agent"), +// while cast wallet new creates "UTC----
" files. +// We look for the known account name first, then fall back to any file +// containing valid V3 keystore JSON. +func findKeystoreFile(keysDir string) string { + // Check for the well-known account name first + knownPath := filepath.Join(keysDir, keystoreAccountName) + if info, err := os.Stat(knownPath); err == nil && !info.IsDir() { + return knownPath + } + + // Fall back: scan for any file containing V3 keystore JSON + entries, err := os.ReadDir(keysDir) + if err != nil { + return "" + } + + var newest string + var newestTime time.Time + + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + // Skip known non-keystore files + if name == "password.txt" || filepath.Ext(name) == ".yaml" || filepath.Ext(name) == ".ports" { + continue + } + if !isV3Keystore(filepath.Join(keysDir, name)) { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if newest == "" || info.ModTime().After(newestTime) { + newest = filepath.Join(keysDir, name) + newestTime = info.ModTime() + } + } + return newest +} - configFile := filepath.Join(keysDir, key.KeyID+".yaml") +// isV3Keystore returns true if the file at path contains valid V3 keystore JSON. +func isV3Keystore(path string) bool { + content, err := os.ReadFile(path) + if err != nil { + return false + } + var ks struct { + Version int `json:"version"` + } + return json.Unmarshal(content, &ks) == nil && ks.Version == 3 +} + +// generateRandomPassword creates a cryptographically random alphanumeric password. +func generateRandomPassword(length int) (string, error) { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, length) + max := big.NewInt(int64(len(charset))) + for i := range b { + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + b[i] = charset[n.Int64()] + } + return string(b), nil +} + +// ProvisionKeyFiles writes the Web3Signer key configuration that references +// the V3 encrypted keystore. Web3Signer uses file-keystore type to read +// the encrypted key and password file at startup. +func ProvisionKeyFiles(keysDir string, wallet *WalletKey, label string) error { + // Derive the container-relative paths. The host keysDir maps to + // /data/keys/ inside the web3signer pod via the PVC mount. + keystoreBasename := filepath.Base(wallet.KeystoreFile) + yamlContent := fmt.Sprintf(`type: "file-keystore" +keyType: "SECP256K1" +keystoreFile: "/data/keys/%s" +keystorePasswordFile: "/data/keys/password.txt" +`, keystoreBasename) + + // Use a deterministic filename based on a short address prefix + shortAddr := strings.TrimPrefix(wallet.Address, "0x") + if len(shortAddr) > 8 { + shortAddr = shortAddr[:8] + } + configFile := filepath.Join(keysDir, shortAddr+".yaml") if err := os.WriteFile(configFile, []byte(yamlContent), 0600); err != nil { return fmt.Errorf("failed to write key config: %w", err) } @@ -98,29 +250,28 @@ func Web3SignerKeysPath(cfg *config.Config, id string) string { // MetadataAddress represents a single signing address in the ConfigMap. type MetadataAddress struct { Address string `json:"address"` - PublicKey string `json:"publicKey"` + PublicKey string `json:"publicKey,omitempty"` CreatedAt string `json:"createdAt"` Label string `json:"label"` } -// MetadataPayload is the JSON structure stored in the web3signer-metadata ConfigMap. +// MetadataPayload is the JSON structure stored in the wallet-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 +// ApplyMetadataConfigMap creates or updates the wallet-metadata ConfigMap // in the instance namespace. The frontend reads this for display purposes. -func ApplyMetadataConfigMap(cfg *config.Config, id string, key *Web3SignerKey) error { +func ApplyMetadataConfigMap(cfg *config.Config, id string, address string) error { namespace := fmt.Sprintf("%s-%s", appName, id) payload := MetadataPayload{ InstanceID: id, Addresses: []MetadataAddress{ { - Address: key.Address, - PublicKey: key.PublicKeyHex, + Address: address, CreatedAt: time.Now().UTC().Format(time.RFC3339), Label: fmt.Sprintf("obol-agent-%s", id), }, @@ -133,21 +284,19 @@ func ApplyMetadataConfigMap(cfg *config.Config, id string, key *Web3SignerKey) e return fmt.Errorf("failed to marshal metadata: %w", err) } - // Build ConfigMap YAML configMapYAML := fmt.Sprintf(`apiVersion: v1 kind: ConfigMap metadata: - name: web3signer-metadata + name: wallet-metadata namespace: %s labels: - app.kubernetes.io/component: web3signer + app.kubernetes.io/component: wallet 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") @@ -158,7 +307,7 @@ data: cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to apply web3signer-metadata ConfigMap: %w", err) + return fmt.Errorf("failed to apply wallet-metadata ConfigMap: %w", err) } return nil @@ -197,128 +346,109 @@ func splitLines(s string) []string { return lines } -// applyWeb3SignerMetadata reads the signing key from the provisioned key files -// and creates the web3signer-metadata ConfigMap. Called after helmfile sync +// applyWalletMetadata reads the signing address from the provisioned keystore +// and creates the wallet-metadata ConfigMap. Called after helmfile sync // when the namespace exists. Errors are non-fatal (printed as warnings). -func applyWeb3SignerMetadata(cfg *config.Config, id string) { +func applyWalletMetadata(cfg *config.Config, id string) { keysDir := Web3SignerKeysPath(cfg, id) - // Find a .yaml key file to reconstruct the key info. - // Key files are YAML with an inline privateKey field: - // type: "file-raw" - // keyType: "SECP256K1" - // privateKey: "0xabcdef..." - entries, err := os.ReadDir(keysDir) - if err != nil { - fmt.Printf(" Warning: could not read web3signer keys directory: %v\n", err) + address := extractAddressFromKeystore(keysDir) + if address == "" { + fmt.Printf(" Warning: could not find wallet address in %s\n", keysDir) return } - for _, entry := range entries { - if filepath.Ext(entry.Name()) != ".yaml" { - continue - } - keyID := strings.TrimSuffix(entry.Name(), ".yaml") + if err := ApplyMetadataConfigMap(cfg, id, address); err != nil { + fmt.Printf(" Warning: could not create wallet-metadata ConfigMap: %v\n", err) + } else { + fmt.Printf("βœ“ Wallet metadata published (Agent address: %s)\n", address) + fmt.Printf(" Back up your key: cp -r %s/ ~/obol-wallet-backup-%s/\n", keysDir, id) + fmt.Println(" WARNING: This wallet feature is in alpha and may change rapidly.") + fmt.Println(" Do not deposit mainnet funds you are not willing to lose.") + } +} - content, err := os.ReadFile(filepath.Join(keysDir, entry.Name())) - if err != nil { - fmt.Printf(" Warning: could not read key file: %v\n", err) - continue - } +// extractAddressFromKeystore reads V3 JSON keystore files in the directory +// and returns the address from the first one found. Keystore files may have +// any extension (cast wallet import uses no extension, cast wallet new uses +// UTC--timestamp--address format). +func extractAddressFromKeystore(keysDir string) string { + // Check for the well-known account name first + knownPath := filepath.Join(keysDir, keystoreAccountName) + if addr := readAddressFromKeystoreFile(knownPath); addr != "" { + return addr + } - // Extract the privateKey value from the YAML content. - privHex := extractPrivateKeyFromYAML(string(content)) - if privHex == "" { - fmt.Printf(" Warning: no privateKey found in %s\n", entry.Name()) - continue - } + // Fall back: scan all files + entries, err := os.ReadDir(keysDir) + if err != nil { + return "" + } - privBytes, err := hex.DecodeString(privHex) - if err != nil { - fmt.Printf(" Warning: invalid key hex in %s: %v\n", entry.Name(), err) + for _, entry := range entries { + if entry.IsDir() { 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, + name := entry.Name() + if name == "password.txt" || filepath.Ext(name) == ".yaml" || filepath.Ext(name) == ".ports" { + continue } - - 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 (Agent address: %s)\n", key.Address) - fmt.Printf(" Back up your key: cp -r %s/ ~/obol-wallet-backup-%s/\n", keysDir, id) - fmt.Println(" WARNING: This wallet feature is in alpha and may change rapidly.") - fmt.Println(" Do not deposit mainnet funds you are not willing to lose.") + if addr := readAddressFromKeystoreFile(filepath.Join(keysDir, name)); addr != "" { + return addr } - return // only process the first key } + return "" } -// extractPrivateKeyFromYAML parses a Web3Signer key YAML file and returns -// the raw private key hex (without 0x prefix). Returns empty string if not found. -func extractPrivateKeyFromYAML(content string) string { - for _, line := range strings.Split(content, "\n") { - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "privateKey:") { - continue - } - // privateKey: "0xabcdef..." - val := strings.TrimPrefix(line, "privateKey:") - val = strings.TrimSpace(val) - val = strings.Trim(val, `"'`) - val = strings.TrimPrefix(val, "0x") - return val +// readAddressFromKeystoreFile reads a single file and extracts the address +// if it's a valid V3 keystore JSON. +func readAddressFromKeystoreFile(path string) string { + content, err := os.ReadFile(path) + if err != nil { + return "" } - return "" + var ks struct { + Address string `json:"address"` + Version int `json:"version"` + } + if json.Unmarshal(content, &ks) != nil || ks.Version != 3 || ks.Address == "" { + return "" + } + addr := ks.Address + if !strings.HasPrefix(addr, "0x") { + addr = "0x" + addr + } + return addr } // 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. +// an existing deployment. If not, it generates them. 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 + // Check if values and keystore already exist 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()) == ".yaml" { - return // Both values and key exist β€” nothing to do - } - } + if findKeystoreFile(keysDir) != "" { + return // Both values and keystore exist β€” nothing to do } } - // Generate signing key - fmt.Println("\nProvisioning Web3Signer for existing deployment...") - signingKey, err := GenerateSigningKey() + // Generate signing key via cast (V3 encrypted keystore) + fmt.Println("\nProvisioning wallet for existing deployment...") + wallet, err := GenerateKeystoreViaCast(keysDir) if err != nil { - fmt.Printf(" Warning: could not generate signing key: %v\n", err) + fmt.Printf(" Warning: could not generate keystore: %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) + if err := ProvisionKeyFiles(keysDir, wallet, keyLabel); err != nil { + fmt.Printf(" Warning: could not provision key config: %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) + fmt.Printf(" βœ“ Agent wallet address: %s\n", wallet.Address) + fmt.Printf(" Back up your key: cp -r %s/ ~/obol-wallet-backup-%s/\n", keysDir, id) // Write values file web3signerValues := generateWeb3SignerValues(id) diff --git a/internal/openclaw/web3signer_test.go b/internal/openclaw/web3signer_test.go index b48520a..818aa31 100644 --- a/internal/openclaw/web3signer_test.go +++ b/internal/openclaw/web3signer_test.go @@ -1,7 +1,6 @@ package openclaw import ( - "encoding/hex" "encoding/json" "os" "path/filepath" @@ -11,157 +10,180 @@ import ( "github.com/ObolNetwork/obol-stack/internal/config" ) -func TestGenerateSigningKey(t *testing.T) { - key, err := GenerateSigningKey() - if err != nil { - t.Fatalf("GenerateSigningKey() error: %v", err) - } +func TestProvisionKeyFiles(t *testing.T) { + dir := t.TempDir() - // 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) + // Create a fake keystore file (simulating what cast wallet new creates) + keystoreFile := filepath.Join(dir, "obol-agent") + if err := os.WriteFile(keystoreFile, []byte(`{"address":"abcdef1234567890abcdef1234567890abcdef12","crypto":{},"id":"test","version":3}`), 0600); err != nil { + t.Fatal(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)) + wallet := &WalletKey{ + Address: "0xabcdef1234567890abcdef1234567890abcdef12", + KeystoreFile: keystoreFile, + PasswordFile: filepath.Join(dir, "password.txt"), } - // 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)) + err := ProvisionKeyFiles(dir, wallet, "test-agent") + if err != nil { + t.Fatalf("ProvisionKeyFiles() error: %v", err) } - // KeyID should be 8 hex chars (4 bytes) - if len(key.KeyID) != 8 { - t.Errorf("KeyID length = %d, want 8", len(key.KeyID)) + // Check .yaml key config file exists with file-keystore reference + yamlFile := filepath.Join(dir, "abcdef12.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) } -} -func TestGenerateSigningKey_Unique(t *testing.T) { - key1, err := GenerateSigningKey() + yamlContent, err := os.ReadFile(yamlFile) if err != nil { - t.Fatalf("first GenerateSigningKey() error: %v", err) + t.Fatalf("failed to read yaml key config: %v", err) } - key2, err := GenerateSigningKey() - if err != nil { - t.Fatalf("second GenerateSigningKey() error: %v", err) + yaml := string(yamlContent) + if !strings.Contains(yaml, `type: "file-keystore"`) { + t.Error("yaml should contain type: file-keystore") } - - if key1.PrivateKeyHex == key2.PrivateKeyHex { - t.Error("two generated keys should not have the same private key") + if !strings.Contains(yaml, `keystoreFile: "/data/keys/obol-agent"`) { + t.Error("yaml should reference keystore file by name") } - if key1.Address == key2.Address { - t.Error("two generated keys should not have the same address") + if !strings.Contains(yaml, `keystorePasswordFile: "/data/keys/password.txt"`) { + t.Error("yaml should reference password file at container path") } - if key1.KeyID == key2.KeyID { - t.Error("two generated keys should not have the same key ID") + if !strings.Contains(yaml, `keyType: "SECP256K1"`) { + t.Error("yaml should specify SECP256K1 key type") } } -func TestProvisionKeyFiles(t *testing.T) { +func TestFindKeystoreFile_AccountName(t *testing.T) { dir := t.TempDir() - key := &Web3SignerKey{ - PrivateKeyHex: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - PublicKeyHex: "0x04" + strings.Repeat("ab", 64), - Address: "0x" + strings.Repeat("cd", 20), - KeyID: "testkey1", + // cast wallet import creates files named after the account + keystorePath := filepath.Join(dir, "obol-agent") + if err := os.WriteFile(keystorePath, []byte(`{"version":3}`), 0600); err != nil { + t.Fatal(err) } - err := ProvisionKeyFiles(dir, key, "test-agent") - if err != nil { - t.Fatalf("ProvisionKeyFiles() error: %v", err) + got := findKeystoreFile(dir) + if got != keystorePath { + t.Errorf("findKeystoreFile() = %q, want %q", got, keystorePath) } +} - // 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) - } +func TestFindKeystoreFile_SkipsNonKeystore(t *testing.T) { + dir := t.TempDir() - yamlContent, err := os.ReadFile(yamlFile) - if err != nil { - t.Fatalf("failed to read yaml key config: %v", err) + // Write files that should be skipped + os.WriteFile(filepath.Join(dir, "password.txt"), []byte("pw"), 0600) + os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("type: file-keystore"), 0600) + + got := findKeystoreFile(dir) + if got != "" { + t.Errorf("findKeystoreFile() should return empty for non-keystore files, got %q", got) } - yaml := string(yamlContent) - if !strings.Contains(yaml, `type: "file-raw"`) { - t.Error("yaml should contain type: file-raw") +} + +func TestExtractAddressFromKeystore(t *testing.T) { + dir := t.TempDir() + + keystore := `{"address":"2a94386c1e32628b15d155a387f3ca2d406d7cb3","crypto":{},"id":"test","version":3}` + if err := os.WriteFile(filepath.Join(dir, "obol-agent"), []byte(keystore), 0600); err != nil { + t.Fatal(err) } - if !strings.Contains(yaml, `privateKey: "0x`+key.PrivateKeyHex+`"`) { - t.Error("yaml should contain inline private key with 0x prefix") + + address := extractAddressFromKeystore(dir) + if address != "0x2a94386c1e32628b15d155a387f3ca2d406d7cb3" { + t.Errorf("extractAddressFromKeystore() = %q, want 0x-prefixed address", address) } - if !strings.Contains(yaml, `keyType: "SECP256K1"`) { - t.Error("yaml should specify SECP256K1 key type") +} + +func TestExtractAddressFromKeystore_Empty(t *testing.T) { + dir := t.TempDir() + address := extractAddressFromKeystore(dir) + if address != "" { + t.Errorf("extractAddressFromKeystore() on empty dir = %q, want empty", address) } } -func TestProvisionKeyFiles_CreatesDirectory(t *testing.T) { - dir := filepath.Join(t.TempDir(), "nested", "keys") +func TestParseAddressFromOutput(t *testing.T) { + tests := []struct { + name string + output string + want string + wantErr bool + }{ + { + name: "cast wallet new output", + output: `Created new encrypted keystore file: /tmp/keys/UTC--2026-02-22--abcdef +Address: 0x2a94386c1e32628b15d155a387f3cA2D406d7Cb3`, + want: "0x2a94386c1e32628b15d155a387f3cA2D406d7Cb3", + }, + { + name: "skips private key line", + output: `Private key: 0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab +Address: 0x1234567890abcdef1234567890abcdef12345678`, + want: "0x1234567890abcdef1234567890abcdef12345678", + }, + { + name: "no address found", + output: "some random output", + wantErr: true, + }, + } - key := &Web3SignerKey{ - PrivateKeyHex: strings.Repeat("ab", 32), - KeyID: "k1", + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseAddressFromOutput(tt.output) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("parseAddressFromOutput() = %q, want %q", got, tt.want) + } + }) } +} - err := ProvisionKeyFiles(dir, key, "test") +func TestGenerateRandomPassword(t *testing.T) { + pw1, err := generateRandomPassword(32) if err != nil { - t.Fatalf("ProvisionKeyFiles() should create nested dirs: %v", err) + t.Fatalf("generateRandomPassword() error: %v", err) + } + if len(pw1) != 32 { + t.Errorf("password length = %d, want 32", len(pw1)) } - if _, err := os.Stat(filepath.Join(dir, "k1.yaml")); os.IsNotExist(err) { - t.Error("key config not created in nested directory") + pw2, _ := generateRandomPassword(32) + if pw1 == pw2 { + t.Error("two generated passwords should not be identical") } } 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) { @@ -170,7 +192,6 @@ func TestMetadataPayload_JSON(t *testing.T) { Addresses: []MetadataAddress{ { Address: "0x1234567890abcdef1234567890abcdef12345678", - PublicKey: "0x04abcd", CreatedAt: "2026-02-20T14:30:00Z", Label: "obol-agent-test-id", }, @@ -197,46 +218,25 @@ func TestMetadataPayload_JSON(t *testing.T) { 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) + t.Error("both releases should target the same namespace") } } @@ -252,49 +252,6 @@ func TestWeb3SignerKeysPath(t *testing.T) { } } -func TestExtractPrivateKeyFromYAML(t *testing.T) { - tests := []struct { - name string - content string - want string - }{ - { - name: "standard format", - content: `type: "file-raw" -keyType: "SECP256K1" -privateKey: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" -`, - want: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - }, - { - name: "single quotes", - content: `type: "file-raw" -privateKey: '0xdeadbeef' -`, - want: "deadbeef", - }, - { - name: "no privateKey field", - content: `type: "file-raw"`, - want: "", - }, - { - name: "empty content", - content: "", - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := extractPrivateKeyFromYAML(tt.content) - if got != tt.want { - t.Errorf("extractPrivateKeyFromYAML() = %q, want %q", got, tt.want) - } - }) - } -} - func TestIndentJSON(t *testing.T) { input := `{ "foo": "bar", @@ -303,11 +260,9 @@ func TestIndentJSON(t *testing.T) { 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/plans/foundry-skill-enhancements.md b/plans/foundry-skill-enhancements.md new file mode 100644 index 0000000..3171612 --- /dev/null +++ b/plans/foundry-skill-enhancements.md @@ -0,0 +1,66 @@ +# Plan: Foundry-Enhanced OpenClaw Skills + +## Context + +The Foundry init container now makes `cast` available at `/tools/cast` inside OpenClaw pods. +This plan upgrades existing skills to leverage `cast` where it replaces or simplifies Python stdlib HTTP wrappers. + +## Phase 1: ethereum-networks β€” cast-based RPC queries + +**File**: `internal/embed/skills/ethereum-networks/scripts/rpc.sh` + +Add a `cast`-based script alongside `rpc.py`. The shell script is simpler, handles ABI decoding +natively, and supports ENS resolution out of the box. + +**Commands to implement:** +- `balance
` β†’ `cast balance` +- `block [number|latest]` β†’ `cast block` +- `tx ` β†’ `cast tx` +- `receipt ` β†’ `cast receipt` +- `call [args...]` β†’ `cast call` (with ABI decoding) +- `chain-id` β†’ `cast chain-id` +- `gas-price` β†’ `cast gas-price` +- `base-fee` β†’ `cast base-fee` +- `nonce
` β†’ `cast nonce` +- `code
` β†’ `cast code` +- `ens ` β†’ `cast resolve-name` +- `from-wei ` β†’ `cast from-wei` +- `to-wei ` β†’ `cast to-wei` +- `4byte ` β†’ `cast 4byte` +- `abi-decode ` β†’ `cast abi-decode` + +**SKILL.md update:** Add cast-based examples, note that `rpc.py` still works as fallback. + +## Phase 2: local-ethereum-wallet β€” cast helpers for tx construction + +**File**: `internal/embed/skills/local-ethereum-wallet/scripts/signer.py` + +Don't replace signer.py yet (still needs Web3Signer for signing until Phase 2 obol-wallet). +Instead, add a helper script for pre-signing operations: + +**File**: `internal/embed/skills/local-ethereum-wallet/scripts/tx-helper.sh` + +- `estimate [data] [value]` β†’ `cast estimate` +- `calldata [args...]` β†’ `cast calldata` +- `decode-tx ` β†’ `cast decode-transaction` +- `interface
` β†’ `cast interface` (fetch ABI from Etherscan) +- `to-wei [unit]` β†’ `cast to-wei` +- `from-wei [unit]` β†’ `cast from-wei` + +**SKILL.md update:** Document these helpers as pre-signing utilities. + +## Phase 3: Update skill constraints + +Remove "no binaries" / "Python stdlib only" caveats from skills where `cast` is now available. +Update constraint sections in: +- `local-ethereum-wallet/SKILL.md` +- `ethereum-networks/SKILL.md` +- `tools/SKILL.md` (note cast is now available in-container) +- `testing/SKILL.md` (note forge is available if needed) +- `gas/SKILL.md` (add cast gas commands as executable examples) + +## Implementation Order + +1. Write `ethereum-networks/scripts/rpc.sh` + update SKILL.md +2. Write `local-ethereum-wallet/scripts/tx-helper.sh` + update SKILL.md +3. Update constraint sections across skills From 63d96cfb5475a15f2bfba3d118e832cb11191c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Mon, 23 Feb 2026 13:09:40 +0000 Subject: [PATCH 14/14] Update chart version --- internal/openclaw/openclaw.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go index ad52a25..b190f29 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -41,7 +41,7 @@ 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.3" + chartVersion = "0.1.5" ) // OnboardOptions contains options for the onboard command