Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
name: obol-ovm
name: deploy-obol-ovm
description: |
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.
---
Expand All @@ -8,6 +8,8 @@ description: |

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.

OVM is the **current-generation** Obol smart-contract family and the default choice for new deployments. For legacy deployments using the older OptimisticWithdrawalRecipient (OWR) + 0xSplits pattern, see the `deploy-obol-splits-legacy` skill instead.

## Prerequisites

- **Foundry (`cast`)** must be installed and on PATH
Expand All @@ -30,35 +32,35 @@ For write operations, always confirm with the user that `PRIVATE_KEY` is set. Ne

## Scripts

All scripts are in `.claude/skills/obol-ovm/scripts/`. Every script accepts an optional network argument (defaults to `mainnet`). Supported networks: `mainnet`, `hoodi`, `sepolia`.
All scripts are in `.claude/skills/deploy-obol-ovm/scripts/`. Every script accepts an optional network argument (defaults to `mainnet`). Supported networks: `mainnet`, `hoodi`, `sepolia`.

Override the default RPC by setting `RPC_URL` env var.

### Verify an Address is an OVM

Before performing write operations on an address, verify it was deployed by the factory:
```bash
.claude/skills/obol-ovm/scripts/check-is-ovm.sh <address> [network]
.claude/skills/deploy-obol-ovm/scripts/check-is-ovm.sh <address> [network]
```
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.

### Read Operations (no key needed)

**Query OVM state:**
```bash
.claude/skills/obol-ovm/scripts/query-ovm.sh <ovm_address> [network]
.claude/skills/deploy-obol-ovm/scripts/query-ovm.sh <ovm_address> [network]
```
Returns owner, principal/reward recipients, threshold, balances, version.

**Query roles for an address:**
```bash
.claude/skills/obol-ovm/scripts/query-roles.sh <ovm_address> <target_address> [network]
.claude/skills/deploy-obol-ovm/scripts/query-roles.sh <ovm_address> <target_address> [network]
```
Returns the decoded role bitmask showing which roles the target has.

**Query EIP-7002/7251 system contract fees:**
```bash
.claude/skills/obol-ovm/scripts/query-fees.sh [network]
.claude/skills/deploy-obol-ovm/scripts/query-fees.sh [network]
```
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.

Expand All @@ -76,65 +78,65 @@ Each write script checks that `PRIVATE_KEY` is set, prints what it's about to do

**Deploy a new OVM:**
```bash
.claude/skills/obol-ovm/scripts/deploy-ovm.sh <owner> <beneficiary> <reward_recipient> [threshold_gwei] [network]
.claude/skills/deploy-obol-ovm/scripts/deploy-ovm.sh <owner> <beneficiary> <reward_recipient> [threshold_gwei] [network]
```
Default threshold is 16 gwei. Deploys via the network's factory contract.

**Grant roles:**
```bash
.claude/skills/obol-ovm/scripts/grant-roles.sh <ovm_address> <target_address> <roles_value> [network]
.claude/skills/deploy-obol-ovm/scripts/grant-roles.sh <ovm_address> <target_address> <roles_value> [network]
```

**Revoke roles:**
```bash
.claude/skills/obol-ovm/scripts/revoke-roles.sh <ovm_address> <target_address> <roles_value> [network]
.claude/skills/deploy-obol-ovm/scripts/revoke-roles.sh <ovm_address> <target_address> <roles_value> [network]
```

**Distribute funds:**
```bash
.claude/skills/obol-ovm/scripts/distribute-funds.sh <ovm_address> [network]
.claude/skills/deploy-obol-ovm/scripts/distribute-funds.sh <ovm_address> [network]
```
Anyone can call this — no special role required.

**Set beneficiary:**
```bash
.claude/skills/obol-ovm/scripts/set-beneficiary.sh <ovm_address> <new_beneficiary> [network]
.claude/skills/deploy-obol-ovm/scripts/set-beneficiary.sh <ovm_address> <new_beneficiary> [network]
```
Requires SET_BENEFICIARY_ROLE (4).

**Set reward recipient:**
```bash
.claude/skills/obol-ovm/scripts/set-reward-recipient.sh <ovm_address> <new_reward_recipient> [network]
.claude/skills/deploy-obol-ovm/scripts/set-reward-recipient.sh <ovm_address> <new_reward_recipient> [network]
```
Requires SET_REWARD_ROLE (16).

**Request validator withdrawal (EIP-7002):**
```bash
.claude/skills/obol-ovm/scripts/withdraw.sh <ovm_address> <pubkeys_csv> <amounts_csv> <max_fee_wei> <excess_fee_recipient> [network]
.claude/skills/deploy-obol-ovm/scripts/withdraw.sh <ovm_address> <pubkeys_csv> <amounts_csv> <max_fee_wei> <excess_fee_recipient> [network]
```
Requires WITHDRAWAL_ROLE (1). Sends ETH = max_fee * num_validators for fees.

**Consolidate validators (EIP-7251):**
```bash
.claude/skills/obol-ovm/scripts/consolidate.sh <ovm_address> <source_pubkey> <dest_pubkey> <max_fee_wei> <excess_fee_recipient> [network]
.claude/skills/deploy-obol-ovm/scripts/consolidate.sh <ovm_address> <source_pubkey> <dest_pubkey> <max_fee_wei> <excess_fee_recipient> [network]
```
Requires CONSOLIDATION_ROLE (2). Consolidates stake from source validator into destination. Sends max_fee as ETH. Query current fees with `query-fees.sh` first.

**Deposit for validator(s):**
```bash
.claude/skills/obol-ovm/scripts/deposit.sh <ovm_address> <deposit_json_path> [network]
.claude/skills/deploy-obol-ovm/scripts/deposit.sh <ovm_address> <deposit_json_path> [network]
```
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.

**Set principal stake amount:**
```bash
.claude/skills/obol-ovm/scripts/set-principal-stake.sh <ovm_address> <new_amount_wei> [network]
.claude/skills/deploy-obol-ovm/scripts/set-principal-stake.sh <ovm_address> <new_amount_wei> [network]
```
Requires owner. Sets `amountOfPrincipalStake` which controls how much of distributed funds goes to the principal recipient. Queries and prints current value before changing.

**Sweep pull balance:**
```bash
.claude/skills/obol-ovm/scripts/sweep.sh <ovm_address> <beneficiary> <amount_wei> [network]
.claude/skills/deploy-obol-ovm/scripts/sweep.sh <ovm_address> <beneficiary> <amount_wei> [network]
```
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.

Expand All @@ -156,11 +158,13 @@ Example: grant WITHDRAWAL + DEPOSIT = pass `33` as the roles value.

## Factory Addresses

| Network | Factory Address |
|---------|----------------|
| mainnet | `0x2c26B5A373294CaccBd3DE817D9B7C6aea7De584` |
| hoodi | `0x5754C8665B7e7BF15E83fCdF6d9636684B782b12` |
| sepolia | `0xF32F8B563d8369d40C45D5d667C2B26937F2A3d3` |
| Network | Factory Address | Deploy Block |
|---------|----------------|--------------|
| mainnet | `0x2c26B5A373294CaccBd3DE817D9B7C6aea7De584` | 23919948 |
| hoodi | `0x5754C8665B7e7BF15E83fCdF6d9636684B782b12` | 1735335 |
| sepolia | `0xF32F8B563d8369d40C45D5d667C2B26937F2A3d3` | 9159573 |

Deploy blocks are the starting point for `cast logs` when enumerating OVMs (see "Listing OVMs on a network" below).

## Default RPCs

Expand All @@ -184,16 +188,16 @@ When `distributeFunds()` is called:
### Deploy and configure a new OVM
```
1. User sets PRIVATE_KEY env var
2. Deploy: .claude/skills/obol-ovm/scripts/deploy-ovm.sh <owner> <beneficiary> <reward> 16 hoodi
2. Deploy: .claude/skills/deploy-obol-ovm/scripts/deploy-ovm.sh <owner> <beneficiary> <reward> 16 hoodi
3. Query tx receipt to get the new OVM address from logs
4. Grant roles: .claude/skills/obol-ovm/scripts/grant-roles.sh <new_ovm> <operator_addr> 33 hoodi
5. Verify: .claude/skills/obol-ovm/scripts/query-roles.sh <new_ovm> <operator_addr> hoodi
4. Grant roles: .claude/skills/deploy-obol-ovm/scripts/grant-roles.sh <new_ovm> <operator_addr> 33 hoodi
5. Verify: .claude/skills/deploy-obol-ovm/scripts/query-roles.sh <new_ovm> <operator_addr> hoodi
```

### Check OVM state and distribute
```
1. Query: .claude/skills/obol-ovm/scripts/query-ovm.sh <ovm_address> mainnet
2. If balance > 0, distribute: .claude/skills/obol-ovm/scripts/distribute-funds.sh <ovm_address> mainnet
1. Query: .claude/skills/deploy-obol-ovm/scripts/query-ovm.sh <ovm_address> mainnet
2. If balance > 0, distribute: .claude/skills/deploy-obol-ovm/scripts/distribute-funds.sh <ovm_address> mainnet
```

### Listing OVMs on a network
Expand All @@ -204,7 +208,7 @@ cast logs --from-block <deploy_block> --to-block latest \
"CreateObolValidatorManager(address indexed,address indexed,address,address,uint64)" \
--rpc-url <rpc>
```
Deploy blocks: mainnet=23919948, hoodi=1735335, sepolia=9159573.
Deploy blocks are listed in the Factory Addresses table above.

## RPC Retry Rule

Expand Down
159 changes: 42 additions & 117 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,144 +2,69 @@

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Obol Splits is a suite of Solidity smart contracts enabling safe creation and management of Distributed Validators for Ethereum Consensus-based networks. Built with Foundry, targeting Solidity 0.8.19 with Shanghai EVM compatibility.

## Development Commands

```sh
# Setup
forge install && cp .env.sample .env

# Testing
forge test # All tests
forge test --match-contract C --match-test t # Specific test
forge test --gas-report # With gas reporting
forge install && cp .env.sample .env # fill in RPC URLs and ETHERSCAN_API_KEY

# Build & Deploy
# Build
forge build
forge script script/DeployFactoryScript.s.sol
```

## Architecture Overview

### Core Contract Types

**ObolValidatorManager (OVM)** - Validator management with ETH2 deposits, withdrawals (EIP-7002), consolidations (EIP-7251)
- 6 role-based permissions: WITHDRAWAL (0x01), CONSOLIDATION (0x02), SET_BENEFICIARY (0x04), RECOVER_FUNDS (0x08), SET_REWARD (0x10), DEPOSIT (0x20)
- PUSH/PULL distribution modes; principal threshold (gwei) routes funds
- `sweep()` extracts from `pullBalances[principalRecipient]` - anyone can call with beneficiary=address(0), owner for custom address
- Non-proxy (deployed via `new`, not Clone)

**OptimisticWithdrawalRecipient (OWR)** - ETH distribution via 16 ETH threshold, Clone proxy, PUSH/PULL modes

**OptimisticTokenWithdrawalRecipient** - OWR for ERC20 with configurable threshold

**ObolLidoSplit** - Wraps stETH→wstETH for 0xSplits (Clone + BaseSplit)

**ObolEtherfiSplit** - Wraps eETH→weETH for 0xSplits (Clone + BaseSplit)

**ImmutableSplitController** - Immutable 0xSplits config (CWIA pattern)

### Factory Pattern

Clone factories (Solady LibClone): OptimisticWithdrawalRecipientFactory, OptimisticTokenWithdrawalRecipientFactory, ObolLidoSplitFactory, ObolEtherfiSplitFactory, ObolCollectorFactory, ImmutableSplitControllerFactory

**Exception**: ObolValidatorManagerFactory deploys full instances via `new` (not clones)

### Key Patterns

- **Clone Proxy**: Minimal proxy with CWIA optimization (all except OVM)
- **Two-Phase Distribution**: PUSH (0) = direct transfer; PULL (1) = deferred via `withdrawPullBalance()`. Prevents malicious recipient DOS.
- **Sweep (OVM)**: Extract from `pullBalances[principalRecipient]`. Anyone if beneficiary=0, owner for custom address. Amount=0 sweeps all.
- **BaseSplit**: Abstract base with distribute(), rescueFunds(), fee mechanism (PERCENTAGE_SCALE=1e5)
- **Rebasing Wrapping**: stETH→wstETH, eETH→weETH for 0xSplits

## Constants & External Integrations

**Constants:**
- `BALANCE_CLASSIFICATION_THRESHOLD_GWEI = 16 ether / 1 gwei` (OVM tests), `BALANCE_CLASSIFICATION_THRESHOLD = 16 ether` (OWR)
- `PERCENTAGE_SCALE = 1e5`, `PUBLIC_KEY_LENGTH = 48`, Distribution modes: `PUSH = 0, PULL = 1`
- OVM Roles: WITHDRAWAL (0x01), CONSOLIDATION (0x02), SET_BENEFICIARY (0x04), RECOVER_FUNDS (0x08), SET_REWARD (0x10), DEPOSIT (0x20)

**Integrations:** 0xSplits (SplitMain), Lido (stETH/wstETH), EtherFi (eETH/weETH), Deposit Contract (ETH2), EIP-7002 (0x09Fc...aAaA), EIP-7251 (0x0043...EFf6), ENS Reverse Registrar

## Project Structure
# Test
forge test -vvv # all tests
forge test --match-contract ObolLidoSplitTest -vvv # specific contract
forge test --match-contract ObolLidoSplitTest --match-test testCanDistribute -vv # specific test
forge test --gas-report # with gas reporting

# Format
forge fmt
```
src/
├── base/ BaseSplit and BaseSplitFactory abstracts
├── collector/ ObolCollector for reward collection
├── controllers/ ImmutableSplitController
├── etherfi/ EtherFi integration (eETH → weETH)
├── interfaces/ All interface definitions (IObolValidatorManager, etc.)
├── lido/ Lido integration (stETH → wstETH)
├── ovm/ ObolValidatorManager and ObolValidatorManagerFactory
├── owr/ OptimisticWithdrawalRecipient (ETH distribution)
│ └── token/ Token-based withdrawal recipient
└── test/ Test suite organized by feature
├── ovm/ OVM tests and mocks
├── owr/ OWR tests
└── ...

script/ Deployment and management scripts
├── ovm/ OVM-specific scripts (12 scripts)
│ ├── DeployFactoryScript.s.sol
│ ├── CreateOVMScript.s.sol
│ ├── DepositScript.s.sol
│ ├── DistributeFundsScript.s.sol
│ ├── ConsolidateScript.s.sol
│ ├── WithdrawScript.s.sol
│ ├── GrantRolesScript.s.sol
│ ├── SetBeneficiaryScript.s.sol
│ ├── SetRewardRecipientScript.s.sol
│ ├── SetAmountOfPrincipalStakeScript.s.sol
│ ├── SystemContractFeesScript.s.sol
│ └── Utils.s.sol
├── splits/ 0xSplits deployment scripts
└── data/ Sample configuration JSON files
```

## Testing & Security
Integration tests require `MAINNET_RPC_URL` and/or `SEPOLIA_RPC_URL` in `.env` for forked tests.

## Architecture

**Testing:**
- Unit tests (role checks, fees, distribution), integration tests (lido/, etherfi/, owr/token/integration/)
- Mocks: SystemContractMock (EIP-7002/7251), DepositContractMock, MockERC20/1155/NFT
- 100 fuzz runs, .t.sol suffix, 43+ OVM tests (PUSH/PULL, sweep, roles, edge cases)
- OVM test pattern: ≥16 ether → beneficiary, <16 ether → reward
Solidity 0.8.19 on Foundry, targeting Shanghai EVM. The codebase provides composable contracts for Distributed Validator fund distribution, integrating with 0xSplits, Lido, Ether.fi, and Ethereum consensus layer contracts.

**Security:**
- ReentrancyGuard on OVM distribute/sweep
- PUSH/PULL prevents DOS, role-based access (6 OVM roles)
- Fund recovery via `recoverFunds()`, 48-byte pubkey validation
- Sweep allows emergency extraction, fee validation with refunds
- `fundsPendingWithdrawal` prevents over-distribution
### Core Pattern: Factory + Clone

## Deployment Addresses
Nearly every contract has a corresponding factory that deploys minimal proxies using solady's `LibClone` (CWIA — Constructor With Immutable Arguments stored in code, not storage). **Exception**: `ObolValidatorManagerFactory` deploys full instances via `new`.

**Mainnet:** OWRFactory: 0x119acd7844cbdd5fc09b1c6a4408f490c8f7f522, OWR: 0xe11eabf19a49c389d3e8735c35f8f34f28bdcb22, ObolLidoSplitFactory: 0xA9d94139A310150Ca1163b5E23f3E1dbb7D9E2A6, ObolLidoSplit: 0x2fB59065F049e0D0E3180C6312FA0FeB5Bbf0FE3, IMSCFactory: 0x49e7cA187F1E94d9A0d1DFBd6CCCd69Ca17F56a4, IMSC: 0xaF129979b773374dD3025d3F97353e73B0A6Cc8d
### Contract Modules

**Sepolia:** OWRFactory: 0xca78f8fda7ec13ae246e4d4cd38b9ce25a12e64a, OWR: 0x99585e71ab1118682d51efefca0a170c70eef0d6
| Module | Key Contract | Purpose |
|--------|-------------|---------|
| `src/base/` | `BaseSplit`, `BaseSplitFactory` | Abstract base for all splitting contracts. Fee mechanism: `PERCENTAGE_SCALE = 1e5` |
| `src/collector/` | `ObolCollector` | Generic ETH/ERC20 reward collector with fee + distribute |
| `src/lido/` | `ObolLidoSplit` | Wraps rebasing stETH→wstETH before distributing to SplitWallet |
| `src/etherfi/` | `ObolEtherfiSplit` | Same pattern: wraps rebasing eETH→weETH |
| `src/owr/` | `OptimisticWithdrawalRecipient` | ETH-only withdrawal with principal/reward threshold (16 ether). `src/owr/token/` adds ERC20 support |
| `src/ovm/` | `ObolValidatorManager` | Validator lifecycle management: deposit, consolidation (EIP-7251), withdrawal (EIP-7002), distribution. Role-based access via solady `OwnableRoles` |
| `src/controllers/` | `ImmutableSplitController` | Manages 0xSplits config updates with hardcoded recipients |

## Notes
### Key Design Decisions

Solidity 0.8.19, Shanghai EVM, gas reports enabled, audited (https://docs.obol.tech/docs/sec/smart_contract_audit), formatting: 2-space tabs, 120 char lines, no bracket spacing
- **ETH as `address(0)`**: Throughout the codebase, native ETH is represented as `address(0)` in token parameters.
- **PUSH/PULL distribution**: `PUSH (0)` transfers directly; `PULL (1)` defers via `withdrawPullBalance()`. Pull mode prevents malicious recipient DOS.
- **Binary recipients**: OWR and OVM support exactly 2 recipients (principal and reward), routed by balance threshold.
- **Rebasing wrapping**: Lido and Ether.fi integrations wrap rebasing tokens before sending to 0xSplits, which can't handle rebasing tokens natively.
- **SafeTransferLib**: All token transfers use solmate's `SafeTransferLib`.

## OVM Workflows
### Test Organization

**Lifecycle:**
1. Deploy: `ObolValidatorManagerFactory.createObolValidatorManager(owner, beneficiary, rewardRecipient, principalThreshold)`
2. Grant roles: `grantRoles(user, DEPOSIT_ROLE | WITHDRAWAL_ROLE)`
3. Deposit: `deposit(pubkey, withdrawal_credentials, signature, deposit_data_root)` with 32 ETH
4. Distribute: `distributeFunds()` (PUSH) or `distributeFundsPull()` (PULL), then `withdrawPullBalance(account)`
5. Emergency: `sweep(address(0), 0)` extracts all principal pull balance to beneficiary
Tests in `src/test/` mirror the source structure. Each module has:
- Unit tests (`.t.sol` suffix)
- Test helpers (e.g., `ObolLidoSplitTestHelper.sol`)
- `integration/` subdirectories for fork-based tests
- Mocks in `src/test/utils/mocks/` and `src/test/ovm/mocks/`

**Distribution:** If `balance - fundsPendingWithdrawal >= principalThreshold * 1e9` AND `amountOfPrincipalStake > 0`: pay principal first (up to `amountOfPrincipalStake`), overflow to reward. Else: all to reward. `amountOfPrincipalStake` decrements on payout.
Fuzz testing configured at 100 runs.

**Sweep:** `sweep(address(0), amount)` anyone→principalRecipient; `sweep(customAddr, amount)` owner→custom; `sweep(address(0), 0)` sweeps all
### Formatting

**EIP-7002 Withdrawals:** `withdraw(pubKeys, amounts, maxFeePerWithdrawal, excessFeeRecipient)` - requires WITHDRAWAL_ROLE, ETH for `fee * pubKeys.length`
Configured in `foundry.toml [fmt]`: 2-space indentation, 120 char line length, no bracket spacing, double quotes, `attributes_first` multiline func headers.

**EIP-7251 Consolidations:** `consolidate(requests, maxFeePerConsolidation, excessFeeRecipient)` - requires CONSOLIDATION_ROLE, max 63 source pubkeys per request
### Deployment Scripts

Scripts in `script/`, with OVM-specific scripts in `script/ovm/`. Lido deployment uses JSON config files from `script/data/`.
Loading
Loading