Skip to content

Commit 5ebf81a

Browse files
authored
Merge branch 'main' into oisin/cleanup
Signed-off-by: Oisín Kyne <4981644+OisinKyne@users.noreply.github.com>
2 parents be089ac + f4b0b74 commit 5ebf81a

17 files changed

Lines changed: 943 additions & 1 deletion

.claude/skills/obol-ovm/SKILL.md

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
---
2+
name: obol-ovm
3+
description: |
4+
Manage Obol Validator Manager (OVM) smart contracts on Ethereum. Use this skill for any OVM operation: querying contract state, deploying new OVMs, managing roles (grant/revoke), distributing funds, setting beneficiaries or reward recipients, and requesting validator withdrawals. Trigger this skill whenever the user mentions OVM, Obol Validator Manager, validator management, distributed validators, or wants to interact with OVM contracts on mainnet/hoodi/sepolia.
5+
---
6+
7+
# Obol Validator Manager (OVM) Skill
8+
9+
This skill provides scripts and knowledge to manage OVM contracts on Ethereum. OVM contracts manage distributed validators — handling ETH deposits, withdrawals (EIP-7002), consolidations (EIP-7251), and fund distribution to principal and reward recipients.
10+
11+
## Prerequisites
12+
13+
- **Foundry (`cast`)** must be installed and on PATH
14+
- **Read operations** work without any keys — they use public RPCs
15+
- **Write operations** require `PRIVATE_KEY` env var to be set by the user before running
16+
17+
### Environment Variables
18+
19+
The user sets these in their shell before running scripts:
20+
21+
```bash
22+
# Required for write operations (the scripts pass this to cast, never read or log it)
23+
export PRIVATE_KEY=0x<private_key>
24+
25+
# Optional: override default RPC for any network
26+
export RPC_URL=https://your-preferred-rpc.com
27+
```
28+
29+
For write operations, always confirm with the user that `PRIVATE_KEY` is set. Never attempt to read, echo, or log the key — the scripts pass it directly to `cast` as `$PRIVATE_KEY`.
30+
31+
## Scripts
32+
33+
All scripts are in `.claude/skills/obol-ovm/scripts/`. Every script accepts an optional network argument (defaults to `mainnet`). Supported networks: `mainnet`, `hoodi`, `sepolia`.
34+
35+
Override the default RPC by setting `RPC_URL` env var.
36+
37+
### Verify an Address is an OVM
38+
39+
Before performing write operations on an address, verify it was deployed by the factory:
40+
```bash
41+
.claude/skills/obol-ovm/scripts/check-is-ovm.sh <address> [network]
42+
```
43+
Queries `CreateObolValidatorManager` event logs from the factory. Exits 0 if the address is an OVM, exits 1 if not. Run this before grant-roles, revoke-roles, distribute, set-beneficiary, set-reward-recipient, or withdraw to catch mistakes early.
44+
45+
### Read Operations (no key needed)
46+
47+
**Query OVM state:**
48+
```bash
49+
.claude/skills/obol-ovm/scripts/query-ovm.sh <ovm_address> [network]
50+
```
51+
Returns owner, principal/reward recipients, threshold, balances, version.
52+
53+
**Query roles for an address:**
54+
```bash
55+
.claude/skills/obol-ovm/scripts/query-roles.sh <ovm_address> <target_address> [network]
56+
```
57+
Returns the decoded role bitmask showing which roles the target has.
58+
59+
**Query EIP-7002/7251 system contract fees:**
60+
```bash
61+
.claude/skills/obol-ovm/scripts/query-fees.sh [network]
62+
```
63+
Returns current withdrawal fee (EIP-7002) and consolidation fee (EIP-7251) in wei. Useful before calling withdraw or consolidate to know how much ETH to send.
64+
65+
You can also query OVM state directly with `cast call` for individual fields:
66+
```bash
67+
cast call <ovm> "owner()(address)" --rpc-url <rpc>
68+
cast call <ovm> "principalRecipient()(address)" --rpc-url <rpc>
69+
cast call <ovm> "rolesOf(address)(uint256)" <addr> --rpc-url <rpc>
70+
cast balance <ovm> --rpc-url <rpc>
71+
```
72+
73+
### Write Operations (require PRIVATE_KEY)
74+
75+
Each write script checks that `PRIVATE_KEY` is set, prints what it's about to do, then executes via `cast send`.
76+
77+
**Deploy a new OVM:**
78+
```bash
79+
.claude/skills/obol-ovm/scripts/deploy-ovm.sh <owner> <beneficiary> <reward_recipient> [threshold_gwei] [network]
80+
```
81+
Default threshold is 16 gwei. Deploys via the network's factory contract.
82+
83+
**Grant roles:**
84+
```bash
85+
.claude/skills/obol-ovm/scripts/grant-roles.sh <ovm_address> <target_address> <roles_value> [network]
86+
```
87+
88+
**Revoke roles:**
89+
```bash
90+
.claude/skills/obol-ovm/scripts/revoke-roles.sh <ovm_address> <target_address> <roles_value> [network]
91+
```
92+
93+
**Distribute funds:**
94+
```bash
95+
.claude/skills/obol-ovm/scripts/distribute-funds.sh <ovm_address> [network]
96+
```
97+
Anyone can call this — no special role required.
98+
99+
**Set beneficiary:**
100+
```bash
101+
.claude/skills/obol-ovm/scripts/set-beneficiary.sh <ovm_address> <new_beneficiary> [network]
102+
```
103+
Requires SET_BENEFICIARY_ROLE (4).
104+
105+
**Set reward recipient:**
106+
```bash
107+
.claude/skills/obol-ovm/scripts/set-reward-recipient.sh <ovm_address> <new_reward_recipient> [network]
108+
```
109+
Requires SET_REWARD_ROLE (16).
110+
111+
**Request validator withdrawal (EIP-7002):**
112+
```bash
113+
.claude/skills/obol-ovm/scripts/withdraw.sh <ovm_address> <pubkeys_csv> <amounts_csv> <max_fee_wei> <excess_fee_recipient> [network]
114+
```
115+
Requires WITHDRAWAL_ROLE (1). Sends ETH = max_fee * num_validators for fees.
116+
117+
**Consolidate validators (EIP-7251):**
118+
```bash
119+
.claude/skills/obol-ovm/scripts/consolidate.sh <ovm_address> <source_pubkey> <dest_pubkey> <max_fee_wei> <excess_fee_recipient> [network]
120+
```
121+
Requires CONSOLIDATION_ROLE (2). Consolidates stake from source validator into destination. Sends max_fee as ETH. Query current fees with `query-fees.sh` first.
122+
123+
**Deposit for validator(s):**
124+
```bash
125+
.claude/skills/obol-ovm/scripts/deposit.sh <ovm_address> <deposit_json_path> [network]
126+
```
127+
Requires DEPOSIT_ROLE (32). Reads a deposit data JSON file (standard format from deposit CLI) and executes deposits via `forge script`. Each deposit sends 32 ETH.
128+
129+
**Set principal stake amount:**
130+
```bash
131+
.claude/skills/obol-ovm/scripts/set-principal-stake.sh <ovm_address> <new_amount_wei> [network]
132+
```
133+
Requires owner. Sets `amountOfPrincipalStake` which controls how much of distributed funds goes to the principal recipient. Queries and prints current value before changing.
134+
135+
**Sweep pull balance:**
136+
```bash
137+
.claude/skills/obol-ovm/scripts/sweep.sh <ovm_address> <beneficiary> <amount_wei> [network]
138+
```
139+
Extracts funds from `pullBalances[principalRecipient]`. Pass `0x0000000000000000000000000000000000000000` as beneficiary to sweep to principal recipient (anyone can call). Pass a custom address to sweep there (owner only). Amount=0 sweeps all.
140+
141+
## Role System
142+
143+
OVM uses bitwise role flags. Add values together for multiple roles:
144+
145+
| Role | Value | Purpose |
146+
|------|-------|---------|
147+
| WITHDRAWAL_ROLE | 1 | Request validator withdrawals (EIP-7002) |
148+
| CONSOLIDATION_ROLE | 2 | Consolidate validator stakes (EIP-7251) |
149+
| SET_BENEFICIARY_ROLE | 4 | Change principal recipient |
150+
| RECOVER_FUNDS_ROLE | 8 | Recover stuck funds |
151+
| SET_REWARD_ROLE | 16 | Change reward recipient |
152+
| DEPOSIT_ROLE | 32 | Make validator deposits |
153+
| ALL ROLES | 63 | All of the above combined |
154+
155+
Example: grant WITHDRAWAL + DEPOSIT = pass `33` as the roles value.
156+
157+
## Factory Addresses
158+
159+
| Network | Factory Address |
160+
|---------|----------------|
161+
| mainnet | `0x2c26B5A373294CaccBd3DE817D9B7C6aea7De584` |
162+
| hoodi | `0x5754C8665B7e7BF15E83fCdF6d9636684B782b12` |
163+
| sepolia | `0xF32F8B563d8369d40C45D5d667C2B26937F2A3d3` |
164+
165+
## Default RPCs
166+
167+
| Network | RPC URL |
168+
|---------|---------|
169+
| mainnet | `https://ethereum-rpc.publicnode.com` |
170+
| hoodi | `https://ethereum-hoodi-rpc.publicnode.com` |
171+
| sepolia | `https://sepolia.drpc.org` |
172+
173+
Override any default by setting `RPC_URL` env var before running a script.
174+
175+
## Fund Distribution Logic
176+
177+
When `distributeFunds()` is called:
178+
- If `balance - fundsPendingWithdrawal >= principalThreshold * 1e9` AND `amountOfPrincipalStake > 0`: principal gets paid first (up to `amountOfPrincipalStake`), overflow goes to reward recipient
179+
- Otherwise: everything goes to reward recipient
180+
- `amountOfPrincipalStake` decrements after each principal payout
181+
182+
## Workflow Examples
183+
184+
### Deploy and configure a new OVM
185+
```
186+
1. User sets PRIVATE_KEY env var
187+
2. Deploy: .claude/skills/obol-ovm/scripts/deploy-ovm.sh <owner> <beneficiary> <reward> 16 hoodi
188+
3. Query tx receipt to get the new OVM address from logs
189+
4. Grant roles: .claude/skills/obol-ovm/scripts/grant-roles.sh <new_ovm> <operator_addr> 33 hoodi
190+
5. Verify: .claude/skills/obol-ovm/scripts/query-roles.sh <new_ovm> <operator_addr> hoodi
191+
```
192+
193+
### Check OVM state and distribute
194+
```
195+
1. Query: .claude/skills/obol-ovm/scripts/query-ovm.sh <ovm_address> mainnet
196+
2. If balance > 0, distribute: .claude/skills/obol-ovm/scripts/distribute-funds.sh <ovm_address> mainnet
197+
```
198+
199+
### Listing OVMs on a network
200+
Use `cast logs` against the factory to find all deployed OVMs:
201+
```bash
202+
cast logs --from-block <deploy_block> --to-block latest \
203+
--address <factory_address> \
204+
"CreateObolValidatorManager(address indexed,address indexed,address,address,uint64)" \
205+
--rpc-url <rpc>
206+
```
207+
Deploy blocks: mainnet=23919948, hoodi=1735335, sepolia=9159573.
208+
209+
## RPC Retry Rule
210+
211+
Public RPCs can be unreliable (timeouts, rate limits, incomplete responses). **Never treat an RPC failure as a definitive answer.** All scripts that query the chain should:
212+
213+
1. Retry up to **3 times** with the default public RPC before giving up
214+
2. If all 3 attempts fail, **ask the user to provide a custom RPC** via `export RPC_URL=...` — do NOT report a false negative (e.g. saying an address is not an OVM when the RPC simply failed)
215+
3. Exit with code `2` to distinguish RPC failures from genuine "not found" results (exit code `1`)
216+
217+
This rule applies to `check-is-ovm.sh` and any future scripts that depend on RPC queries for verification.
218+
219+
## Troubleshooting
220+
221+
**"PRIVATE_KEY env var must be set"** — The user needs to export their private key before write operations. Remind them: `export PRIVATE_KEY=0x...`
222+
223+
**RPC timeouts or errors** — Scripts retry 3 times automatically. If they still fail, ask the user for a custom RPC and set `RPC_URL` env var. Free-tier RPCs may have block range limits on log queries.
224+
225+
**"execution reverted"** — Check that the signer has the required role for the operation. Use `query-roles.sh` to verify permissions.
226+
227+
## Security
228+
229+
- Scripts never read, echo, or log `PRIVATE_KEY` — it's only passed as `$PRIVATE_KEY` to `cast send`
230+
- Read operations use public RPCs with no credentials
231+
- Always show the user what transaction will execute before running a write script
232+
- Warn about gas costs on mainnet
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env bash
2+
# Check if an address is an OVM contract by querying factory deployment logs
3+
# Usage: ./check-is-ovm.sh <address> [network]
4+
#
5+
# Queries CreateObolValidatorManager events from the factory contract.
6+
# Handles RPC block range limits by chunking queries (newest blocks first).
7+
# Exits 0 if the address is an OVM, exits 1 if not, exits 2 if RPC failed.
8+
9+
set -euo pipefail
10+
11+
ADDRESS="$(echo "${1:?Usage: check-is-ovm.sh <address> [network]}" | tr '[:upper:]' '[:lower:]')"
12+
NETWORK="${2:-mainnet}"
13+
14+
case "$NETWORK" in
15+
mainnet) FACTORY="0x2c26B5A373294CaccBd3DE817D9B7C6aea7De584"; DEPLOY_BLOCK=23919948; DEFAULT_RPC="https://ethereum-rpc.publicnode.com" ;;
16+
hoodi) FACTORY="0x5754C8665B7e7BF15E83fCdF6d9636684B782b12"; DEPLOY_BLOCK=1735335; DEFAULT_RPC="https://ethereum-hoodi-rpc.publicnode.com" ;;
17+
sepolia) FACTORY="0xF32F8B563d8369d40C45D5d667C2B26937F2A3d3"; DEPLOY_BLOCK=9159573; DEFAULT_RPC="https://sepolia.drpc.org" ;;
18+
*) echo "Error: unsupported network '$NETWORK'. Use: mainnet, hoodi, sepolia" >&2; exit 1 ;;
19+
esac
20+
21+
RPC="${RPC_URL:-$DEFAULT_RPC}"
22+
CHUNK_SIZE=50000
23+
MAX_RETRIES=3
24+
25+
echo "Checking if $ADDRESS is an OVM on $NETWORK..."
26+
27+
# Event: CreateObolValidatorManager(address indexed ovm, address indexed owner, address beneficiary, address rewardRecipient, uint64 principalThreshold)
28+
TOPIC0="$(cast keccak 'CreateObolValidatorManager(address,address,address,address,uint64)')"
29+
PADDED_ADDR="0x000000000000000000000000${ADDRESS#0x}"
30+
31+
# Get current block number
32+
LATEST_BLOCK=$(cast block-number --rpc-url "$RPC" 2>&1) || {
33+
echo "Error: Could not fetch latest block number from RPC" >&2
34+
echo "Please set a custom RPC: export RPC_URL=https://your-rpc-url" >&2
35+
exit 2
36+
}
37+
38+
# Query in chunks from newest to oldest (most recent deployments queried first)
39+
TO_BLOCK=$LATEST_BLOCK
40+
while [ "$TO_BLOCK" -ge "$DEPLOY_BLOCK" ]; do
41+
FROM_BLOCK=$((TO_BLOCK - CHUNK_SIZE + 1))
42+
if [ "$FROM_BLOCK" -lt "$DEPLOY_BLOCK" ]; then
43+
FROM_BLOCK=$DEPLOY_BLOCK
44+
fi
45+
46+
for ATTEMPT in $(seq 1 $MAX_RETRIES); do
47+
LOGS=$(cast logs \
48+
--from-block "$FROM_BLOCK" \
49+
--to-block "$TO_BLOCK" \
50+
--address "$FACTORY" \
51+
"$TOPIC0" \
52+
"$PADDED_ADDR" \
53+
--rpc-url "$RPC" 2>&1)
54+
EXIT_CODE=$?
55+
56+
# Found matching logs — it's an OVM
57+
if echo "$LOGS" | grep -qi "topic"; then
58+
echo "Yes — $ADDRESS is an OVM deployed by factory $FACTORY"
59+
exit 0
60+
fi
61+
62+
# cast succeeded without errors — this chunk has no match, move to next
63+
if [ $EXIT_CODE -eq 0 ] && ! echo "$LOGS" | grep -qi "error\|timeout\|rate.limit\|failed\|connection\|block range"; then
64+
break
65+
fi
66+
67+
# RPC failed — retry this chunk
68+
if [ "$ATTEMPT" -lt "$MAX_RETRIES" ]; then
69+
echo "RPC failed on blocks $FROM_BLOCK-$TO_BLOCK (attempt $ATTEMPT/$MAX_RETRIES), retrying..." >&2
70+
sleep 2
71+
elif [ "$ATTEMPT" -eq "$MAX_RETRIES" ]; then
72+
echo "" >&2
73+
echo "Error: Failed to query blocks $FROM_BLOCK-$TO_BLOCK after $MAX_RETRIES attempts." >&2
74+
echo "The public RPC may be unreliable. Please set a custom RPC and retry:" >&2
75+
echo " export RPC_URL=https://your-rpc-url" >&2
76+
echo " $0 $*" >&2
77+
exit 2
78+
fi
79+
done
80+
81+
TO_BLOCK=$((FROM_BLOCK - 1))
82+
done
83+
84+
# All chunks queried, no match found
85+
echo "No — $ADDRESS is NOT an OVM on $NETWORK"
86+
exit 1
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/usr/bin/env bash
2+
# Consolidate validator stakes via EIP-7251
3+
# Usage: ./consolidate.sh <ovm_address> <source_pubkey> <dest_pubkey> <max_fee_wei> <excess_fee_recipient> [network]
4+
#
5+
# source_pubkey: 48-byte hex pubkey of the source validator (0x-prefixed)
6+
# dest_pubkey: 48-byte hex pubkey of the destination validator (0x-prefixed)
7+
# max_fee_per_consolidation: in wei
8+
# The total ETH sent = max_fee (one consolidation request)
9+
#
10+
# Requires: CONSOLIDATION_ROLE (0x02) on the OVM
11+
# Requires: PRIVATE_KEY env var set (never read or printed, only passed to cast)
12+
13+
set -euo pipefail
14+
15+
OVM="${1:?Usage: consolidate.sh <ovm> <src_pubkey> <dst_pubkey> <max_fee_wei> <excess_fee_recipient> [network]}"
16+
SRC_PUBKEY="${2:?Missing source pubkey (48 bytes hex, 0x-prefixed)}"
17+
DST_PUBKEY="${3:?Missing destination pubkey (48 bytes hex, 0x-prefixed)}"
18+
MAX_FEE="${4:?Missing max fee per consolidation (wei)}"
19+
EXCESS_RECIPIENT="${5:?Missing excess fee recipient address}"
20+
NETWORK="${6:-mainnet}"
21+
22+
case "$NETWORK" in
23+
mainnet) DEFAULT_RPC="https://ethereum-rpc.publicnode.com" ;;
24+
hoodi) DEFAULT_RPC="https://ethereum-hoodi-rpc.publicnode.com" ;;
25+
sepolia) DEFAULT_RPC="https://sepolia.drpc.org" ;;
26+
*) echo "Error: unsupported network '$NETWORK'. Use: mainnet, hoodi, sepolia" >&2; exit 1 ;;
27+
esac
28+
29+
RPC="${RPC_URL:-$DEFAULT_RPC}"
30+
31+
if [ -z "${PRIVATE_KEY:-}" ]; then
32+
echo "Error: PRIVATE_KEY env var must be set" >&2
33+
exit 1
34+
fi
35+
36+
echo "Consolidating validators on $NETWORK..."
37+
echo " OVM: $OVM"
38+
echo " Source pubkey: $SRC_PUBKEY"
39+
echo " Destination pubkey: $DST_PUBKEY"
40+
echo " Max fee: $MAX_FEE wei"
41+
echo " Excess recipient: $EXCESS_RECIPIENT"
42+
echo ""
43+
44+
# consolidate(ConsolidationRequest[] requests, uint256 maxFeePerConsolidation, address excessFeeRecipient)
45+
# ConsolidationRequest = (bytes[] srcPubKeys, bytes targetPubKey)
46+
# We encode a single request with a single source pubkey
47+
cast send "$OVM" \
48+
"consolidate((bytes[],bytes)[],uint256,address)" \
49+
"[([${SRC_PUBKEY}],${DST_PUBKEY})]" "$MAX_FEE" "$EXCESS_RECIPIENT" \
50+
--value "$MAX_FEE" \
51+
--rpc-url "$RPC" \
52+
--private-key "$PRIVATE_KEY"

0 commit comments

Comments
 (0)