Skip to content
Merged
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
20 changes: 10 additions & 10 deletions .github/workflows/run-forge-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,21 @@ jobs:
if: always()
shell: bash
run: |
set -o pipefail
if forge coverage --report summary --no-match-coverage '(script/|test/|src/helpers/|src/interfaces/|src/eigenlayer|src/libraries/|src/archive/)' --color never 2>&1 | tee full_output.txt | sed -n '/^[|╭╰+]/p' > coverage.txt; then
# Capture full output; || true so the step always continues
forge coverage --report summary \
--no-match-coverage '(script/|test/|src/helpers/|src/interfaces/|src/eigenlayer|src/libraries/|src/archive/)' \
--color never > full_output.txt 2>&1 || true

# Extract coverage table lines
grep -E '^\|' full_output.txt > coverage.txt || true

if [ -s coverage.txt ]; then
echo "" >> coverage.txt
echo "---" >> coverage.txt
grep -E "^(Ran|Suite result:|Test result:)" full_output.txt >> coverage.txt || true
else
echo "forge coverage failed; see logs above (tests step is the gate)." > coverage.txt
echo "Coverage report could not be generated. Check workflow logs for details." > coverage.txt
fi
# Debug output
echo "=== DEBUG: coverage.txt contents (first 20 lines) ==="
head -20 coverage.txt || echo "(empty or missing)"
echo "=== DEBUG: coverage.txt size ==="
wc -l coverage.txt || echo "(file missing)"
echo "=== DEBUG: full_output.txt table lines ==="
grep -cE '^[|╭╰+]' full_output.txt || echo "0 lines match table pattern"
env:
MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }}
SCROLL_RPC_URL: ${{ secrets.SCROLL_RPC_URL }}
Expand Down
99 changes: 99 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# EtherFi Smart Contracts

## Build & Test

```bash
forge build # compile
forge test --match-test <name> # unit tests (no RPC needed)
forge test --match-test <name> --fork-url $MAINNET_RPC_URL # mainnet fork tests
```

- Solidity 0.8.27, Foundry with 1500 optimizer runs
- Env vars: `MAINNET_RPC_URL`, `FORK_RPC_URL` (optional override), `VALIDATOR_DB`, `BEACON_NODE_URL`

## Project Layout

```
src/ # Core contracts
EtherFiNode.sol # Per-validator-group contract, owns an EigenPod
EtherFiNodesManager.sol # Entry point for pod operations (0x8B71...6F)
LiquidityPool.sol # Main ETH pool (0x3088...16)
EtherFiRestaker.sol # Manages stETH restaking via EigenLayer (0x1B7a...Ff)
EtherFiRedemptionManager.sol # Instant redemptions with rate limiting (0xDadE...e0)
StakingManager.sol # Validator lifecycle
WeETH.sol / EETH.sol # Token contracts
eigenlayer-interfaces/ # EigenLayer interface definitions (no implementations)
test/
TestSetup.sol # Base test with initializeRealisticFork() / initializeTestingFork()
behaviour-tests/ # PreludeTest - validator lifecycle on mainnet fork
integration-tests/ # Cross-contract integration tests on mainnet fork
fork-tests/ # Additional fork-based tests
script/
operations/ # Operational tooling (Python + Solidity for Gnosis Safe txns)
deploys/Deployed.s.sol # All mainnet deployed addresses as constants
```

## Architecture

- Validator pubkey -> `etherFiNodeFromPubkeyHash` -> EtherFiNode -> `getEigenPod()` -> EigenPod
- `calculateValidatorPubkeyHash`: `sha256(pubkey + bytes16(0))`
- Legacy validators use integer IDs; new validators use pubkey hashes. `etherfiNodeAddress(id)` resolves both via a heuristic on upper bits.
- UUPS proxy pattern throughout. Upgrades go through timelocks.

## Key Addresses (Mainnet)

| Role | Address |
|------|---------|
| OPERATING_TIMELOCK | `0xcD425f44758a08BaAB3C4908f3e3dE5776e45d7a` |
| UPGRADE_TIMELOCK | `0x9f26d4C958fD811A1F59B01B86Be7dFFc9d20761` |
| ROLE_REGISTRY | `0x62247D29B4B9BECf4BB73E0c722cf6445cfC7cE9` |
| EIGENLAYER_DELEGATION_MANAGER | `0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A` |
| EIGENLAYER_POD_MANAGER | `0x91E677b07F7AF907ec9a428aafA9fc14a0d3A338` |
| LIDO_WITHDRAWAL_QUEUE | `0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1` |

Full list in `script/deploys/Deployed.s.sol`.

## Access Control Roles

- `ETHERFI_NODES_MANAGER_POD_PROVER_ROLE` -> startCheckpoint, verifyCheckpointProofs
- `ETHERFI_NODES_MANAGER_EIGENLAYER_ADMIN_ROLE` -> queueETHWithdrawal, completeQueuedETHWithdrawals
- `ETHERFI_REDEMPTION_MANAGER_ADMIN_ROLE` -> setCapacity, setRefillRate, setLowWatermark, setExitFee
- `OPERATING_TIMELOCK` holds the redemption manager admin role

## Test Setup Patterns

Two fork modes in `TestSetup.sol`:
- `initializeRealisticFork(MAINNET_FORK)` — uses real mainnet contracts at their deployed addresses. Forks at **latest block** (no pinned block), so mainnet state drifts.
- `initializeTestingFork(MAINNET_FORK)` — deploys fresh contracts on a mainnet fork.

`PreludeTest` (behaviour-tests) has its own setup: forks mainnet, upgrades contracts in-place, deploys fresh RateLimiter, grants roles to test addresses (`admin`, `eigenlayerAdmin`, `podProver`, `elExiter`).

## Mainnet Fork Test Decisions

These are hard-won lessons. Follow them when writing or fixing fork tests:

1. **Never assume zero baselines.** Mainnet contracts have live state (pending withdrawals, balances, queued operations). Always capture initial values and assert deltas relative to them.

2. **EigenPod storage slot 52** = `withdrawableRestakedExecutionLayerGwei` (uint64, packed with `proofSubmitter` address in same slot). When poking this with `vm.store`:
- Set it BEFORE any `queueETHWithdrawal` / `completeQueuedETHWithdrawals` calls
- Use a large value (10000+ ETH in gwei) to cover pre-existing queued withdrawals from mainnet state
- Also `vm.deal` ETH to the EigenPod so it can actually transfer funds during withdrawal completion
- `completeQueuedETHWithdrawals` iterates ALL eligible queued withdrawals, not just the one you queued in the test

3. **RedemptionManager lowWatermark blocks redemptions on fork.** `lowWatermarkInETH = totalPooledEther * lowWatermarkInBpsOfTvl / 10000`. On mainnet, TVL is millions of ETH, so even a 1% watermark = tens of thousands ETH. Test deposits of a few thousand ETH can never exceed this. Fix: `setLowWatermarkInBpsOfTvl(0, token)` via `OPERATING_TIMELOCK` at start of test.

4. **Rate limiter (BucketLimiter)** must also be configured in fork tests: `setCapacity()` + `setRefillRatePerSecond()` + `vm.warp(block.timestamp + 1)` to refill.

## EigenLayer Integration

- EigenPod key function selectors: `currentCheckpointTimestamp()` = `0x42ecff2a`, `lastCheckpointTimestamp()` = `0xee94d67c`, `activeValidatorCount()` = `0x2340e8d3`
- EigenPod storage slots used in tests: slot 52 = `withdrawableRestakedExecutionLayerGwei`, slot 57 = `activeValidatorCount`
- Beacon ETH strategy address: `0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0`
- Withdrawal delay: `EIGENLAYER_WITHDRAWAL_DELAY_BLOCKS = 100800` blocks (~14 days)

## Operations Tooling

- Python scripts in `script/operations/` use `validator_utils.py` for shared DB/beacon utilities
- Solidity scripts in same dirs generate Gnosis Safe transactions
- DB tables: `etherfi_validators` (pubkey, id, phase, status, node_address), `MainnetValidators` (pubkey, eigen_pod_contract, etherfi_node_contract)
- Withdrawal credentials format: `0x01 + 22_zero_chars + 40_char_eigenpod_address`
19 changes: 15 additions & 4 deletions script/operations/steth-management/AutomateStEthWithdrawals.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,18 @@ import "forge-std/Script.sol";
import "forge-std/console2.sol";
import {Deployed} from "../../deploys/Deployed.s.sol";
import {EtherFiRestaker} from "../../../src/EtherFiRestaker.sol";
import {ILido, ILidoWithdrawalQueue} from "../../../src/interfaces/ILiquifier.sol";
import {ILido} from "../../../src/interfaces/ILiquifier.sol";


interface ILidoWithdrawalQueue {
function getLastFinalizedRequestId() external view returns (uint256);
function getLastCheckpointIndex() external view returns (uint256);
function findCheckpointHints(uint256[] calldata _requestIds, uint256 _firstIndex, uint256 _lastIndex) external view returns (uint256[] memory hintIds);
function prefinalize(uint256[] calldata _batches, uint256 _maxShareRate) external view returns (uint256 ethToLock, uint256 sharesToBurn);
function finalize(uint256 _lastRequestIdToBeFinalized, uint256 _maxShareRate) external payable;
function FINALIZE_ROLE() external view returns (bytes32);
function getRoleMember(bytes32 _role, uint256 _index) external view returns (address);
}

// Full withdrawal:
// FULL_WITHDRAWAL=true forge script script/operations/steth-claim-withdrawals/AutomateStEthWithdrawals.s.sol --fork-url $MAINNET_RPC_URL -vvvv
Expand All @@ -18,7 +29,7 @@ contract AutomateStEthWithdrawals is Script, Deployed {
EtherFiRestaker constant etherFiRestaker = EtherFiRestaker(payable(ETHERFI_RESTAKER));

function run() external {
ILidoWithdrawalQueue lidoWithdrawalQueue = etherFiRestaker.lidoWithdrawalQueue();
ILidoWithdrawalQueue lidoWithdrawalQueue = ILidoWithdrawalQueue(address(etherFiRestaker.lidoWithdrawalQueue()));
ILido lido = etherFiRestaker.lido();

bool fullWithdrawal = vm.envOr("FULL_WITHDRAWAL", false);
Expand Down Expand Up @@ -100,8 +111,8 @@ contract AutomateStEthWithdrawals is Script, Deployed {
}

// Get checkpoint hints
uint256 lastCheckpointIndex = lidoWithdrawalQueue.getLastCheckpointIndex();
uint256[] memory hints = lidoWithdrawalQueue.findCheckpointHints(claimableIds, 1, lastCheckpointIndex);
uint256 lastCheckpointIndex = ILidoWithdrawalQueue(address(lidoWithdrawalQueue)).getLastCheckpointIndex();
uint256[] memory hints = ILidoWithdrawalQueue(address(lidoWithdrawalQueue)).findCheckpointHints(claimableIds, 1, lastCheckpointIndex);

// Log claim calldata
bytes memory claimCalldata = abi.encodeWithSelector(
Expand Down
18 changes: 12 additions & 6 deletions script/operations/steth-management/ClaimStEthWithdrawals.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import "forge-std/Script.sol";
import "forge-std/console2.sol";
import {Deployed} from "../../deploys/Deployed.s.sol";
import {EtherFiRestaker} from "../../../src/EtherFiRestaker.sol";
import {ILidoWithdrawalQueue} from "../../../src/interfaces/ILiquifier.sol";
// import {ILidoWithdrawalQueue} from "../../../src/interfaces/ILiquifier.sol";

interface ILidoWithdrawalQueue {
function getLastFinalizedRequestId() external view returns (uint256);
function getLastCheckpointIndex() external view returns (uint256);
function findCheckpointHints(uint256[] calldata _requestIds, uint256 _firstIndex, uint256 _lastIndex) external view returns (uint256[] memory hintIds);
}

// forge script script/operations/steth-claim-withdrawals/ClaimStEthWithdrawals.s.sol --fork-url $MAINNET_RPC_URL -vvvv

Expand All @@ -17,14 +23,14 @@ contract ClaimStEthWithdrawals is Script, Deployed {
uint256 startId = 113785; // Set this to the first request you want to claim
uint256 endId = 113863; // Set this to the last request you want to claim

ILidoWithdrawalQueue lidoWithdrawalQueue = etherFiRestaker.lidoWithdrawalQueue();
ILidoWithdrawalQueue lidoWithdrawalQueue = ILidoWithdrawalQueue(address(etherFiRestaker.lidoWithdrawalQueue()));
console2.log("LidoWithdrawalQueue:", address(lidoWithdrawalQueue));

// Cap endId to the last finalized request
uint256 lastFinalizedId = lidoWithdrawalQueue.getLastFinalizedRequestId();
uint256 lastFinalizedId = ILidoWithdrawalQueue(address(lidoWithdrawalQueue)).getLastFinalizedRequestId();
console2.log("Last finalized request ID:", lastFinalizedId);

if (endId > lastFinalizedId) {
if (endId > lastFinalizedId) {
console2.log("WARNING: endId", endId, "exceeds last finalized ID, capping to", lastFinalizedId);
endId = lastFinalizedId;
}
Expand All @@ -40,10 +46,10 @@ contract ClaimStEthWithdrawals is Script, Deployed {
}

// Get checkpoint hints
uint256 lastCheckpointIndex = lidoWithdrawalQueue.getLastCheckpointIndex();
uint256 lastCheckpointIndex = ILidoWithdrawalQueue(address(lidoWithdrawalQueue)).getLastCheckpointIndex();
console2.log("Last checkpoint index:", lastCheckpointIndex);

uint256[] memory hints = lidoWithdrawalQueue.findCheckpointHints(requestIds, 1, lastCheckpointIndex);
uint256[] memory hints = ILidoWithdrawalQueue(address(lidoWithdrawalQueue)).findCheckpointHints(requestIds, 1, lastCheckpointIndex);

console2.log("Hints found for", hints.length, "requests");
for (uint256 i = 0; i < hints.length; i++) {
Expand Down
2 changes: 1 addition & 1 deletion script/upgrades/CrossPodApproval/deploy.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import "forge-std/Script.sol";
import {LiquidityPool} from "../../../src/LiquidityPool.sol";
import {EtherFiNodesManager} from "../../../src/EtherFiNodesManager.sol";
import {Deployed} from "../../deploys/Deployed.s.sol";
import {Utils, ICreate2Factory} from "../../utils/Utils.sol";
import {Utils, ICreate2Factory} from "../../utils/utils.sol";

/**
command:
Expand Down
2 changes: 1 addition & 1 deletion script/upgrades/CrossPodApproval/transactions.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {EtherFiRateLimiter} from "../../../src/EtherFiRateLimiter.sol";
import {IEtherFiNodesManager} from "../../../src/interfaces/IEtherFiNodesManager.sol";
import {ContractCodeChecker} from "../../ContractCodeChecker.sol";
import {Deployed} from "../../deploys/Deployed.s.sol";
import {Utils} from "../../utils/Utils.sol";
import {Utils} from "../../utils/utils.sol";
import {IEigenPodTypes} from "../../../src/eigenlayer-interfaces/IEigenPod.sol";

// forge script script/upgrades/CrossPodApproval/transactions.s.sol:CrossPodApprovalScript --fork-url $MAINNET_RPC_URL -vvvv
Expand Down
11 changes: 6 additions & 5 deletions test/EtherFiRestaker.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,19 @@ contract EtherFiRestakerTest is TestSetup {
uint256 lpBalance = address(liquidityPoolInstance).balance;
uint256 currentEtherFiRestakerTotalPooledEther = etherFiRestakerInstance.getTotalPooledEther();
uint256 currentStEthBalance = stEth.balanceOf(address(etherFiRestakerInstance));
uint256 initialPendingRedemption = etherFiRestakerInstance.getAmountPendingForRedemption(address(stEth));
uint256 amount = 10 ether;

_deposit_stEth(amount);

assertEq(etherFiRestakerInstance.getAmountPendingForRedemption(address(stEth)), 0);
assertEq(etherFiRestakerInstance.getAmountPendingForRedemption(address(stEth)), initialPendingRedemption);

vm.startPrank(owner);
uint256 stEthBalance = stEth.balanceOf(address(etherFiRestakerInstance)) - currentStEthBalance;
uint256[] memory reqIds = etherFiRestakerInstance.stEthRequestWithdrawal(stEthBalance);
vm.stopPrank();

assertApproxEqAbs(etherFiRestakerInstance.getAmountPendingForRedemption(address(stEth)), amount, 2 wei);
assertApproxEqAbs(etherFiRestakerInstance.getAmountPendingForRedemption(address(stEth)), initialPendingRedemption + amount, 2 wei);
assertApproxEqAbs(etherFiRestakerInstance.getTotalPooledEther(), currentEtherFiRestakerTotalPooledEther + amount, 2 wei);
assertApproxEqAbs(liquidityPoolInstance.getTotalPooledEther(), lpTvl + amount, 2 wei);

Expand All @@ -87,7 +88,7 @@ contract EtherFiRestakerTest is TestSetup {
etherFiRestakerInstance.lidoWithdrawalQueue().finalize(reqIds[reqIds.length-1], currentRate);
vm.stopPrank();

assertApproxEqAbs(etherFiRestakerInstance.getAmountPendingForRedemption(address(stEth)), amount, 2 wei);
assertApproxEqAbs(etherFiRestakerInstance.getAmountPendingForRedemption(address(stEth)), initialPendingRedemption + amount, 2 wei);
assertApproxEqAbs(etherFiRestakerInstance.getTotalPooledEther(), currentEtherFiRestakerTotalPooledEther + amount, 2 wei);
assertApproxEqAbs(liquidityPoolInstance.getTotalPooledEther(), lpTvl + amount, 2 wei);

Expand All @@ -97,8 +98,8 @@ contract EtherFiRestakerTest is TestSetup {
uint256[] memory hints = etherFiRestakerInstance.lidoWithdrawalQueue().findCheckpointHints(reqIds, 1, lastCheckPointIndex);
etherFiRestakerInstance.stEthClaimWithdrawals(reqIds, hints);

// the cycle completes
assertApproxEqAbs(etherFiRestakerInstance.getAmountPendingForRedemption(address(stEth)), 0, 2 wei);
// the cycle completes - only the newly requested amount should be redeemed, initial pending remains
assertApproxEqAbs(etherFiRestakerInstance.getAmountPendingForRedemption(address(stEth)), initialPendingRedemption, 2 wei);
assertApproxEqAbs(etherFiRestakerInstance.getTotalPooledEther(), currentEtherFiRestakerTotalPooledEther, 2 wei);
assertApproxEqAbs(address(etherFiRestakerInstance).balance, 0, 2);
assertApproxEqAbs(liquidityPoolInstance.getTotalPooledEther(), lpTvl + amount, 2 wei);
Expand Down
11 changes: 7 additions & 4 deletions test/behaviour-tests/prelude.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -628,13 +628,16 @@ contract PreludeTest is Test, ArrayTestHelper {
vm.stopPrank();
}

// poke withdrawable funds into the restakedExecutionLayerGwei storage slot of the eigenpod
// Must be done before queueing to ensure the EigenPod has sufficient state.
// Use a large value to also cover any pre-existing queued withdrawals on mainnet.
address eigenpod = etherFiNodesManager.getEigenPod(uint256(pubkeyHash));
vm.store(eigenpod, bytes32(uint256(52)) /*slot*/, bytes32(uint256(10000 ether / 1 gwei)));
vm.deal(eigenpod, 10000 ether);

vm.prank(eigenlayerAdmin);
etherFiNodesManager.queueETHWithdrawal(uint256(pubkeyHash), 1 ether);

// poke some withdrawable funds into the restakedExecutionLayerGwei storage slot of the eigenpod
address eigenpod = etherFiNodesManager.getEigenPod(uint256(pubkeyHash));
vm.store(eigenpod, bytes32(uint256(52)) /*slot*/, bytes32(uint256(50 ether / 1 gwei)));

uint256 startingBalance = address(liquidityPool).balance;

vm.roll(block.number + (7200 * 15));
Expand Down
22 changes: 22 additions & 0 deletions test/integration-tests/Validator-Flows.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,28 @@ import "../../src/libraries/DepositDataRootGenerator.sol";
contract ValidatorFlowsIntegrationTest is TestSetup, Deployed {
function setUp() public {
initializeRealisticFork(MAINNET_FORK);

// Handle any pending oracle report that hasn't been processed yet
_syncOracleReportState();
}

/// @dev Advances the admin's lastHandledReportRefSlot to match the oracle's lastPublishedReportRefSlot.
function _syncOracleReportState() internal {
uint32 lastPublished = etherFiOracleInstance.lastPublishedReportRefSlot();
uint32 lastHandled = etherFiAdminInstance.lastHandledReportRefSlot();

if (lastPublished != lastHandled) {
uint32 lastPublishedBlock = etherFiOracleInstance.lastPublishedReportRefBlock();

// EtherFiAdmin slot 209 packs: lastHandledReportRefSlot (4B @ offset 0) +
// lastHandledReportRefBlock (4B @ offset 4) + other fields in higher bytes
bytes32 slot209 = vm.load(address(etherFiAdminInstance), bytes32(uint256(209)));
uint256 val = uint256(slot209);
val &= ~uint256(0xFFFFFFFFFFFFFFFF); // clear low 64 bits (both uint32 fields)
val |= uint256(lastPublished);
val |= uint256(lastPublishedBlock) << 32;
vm.store(address(etherFiAdminInstance), bytes32(uint256(209)), bytes32(val));
}
}

function _toArray(IStakingManager.DepositData memory d) internal pure returns (IStakingManager.DepositData[] memory arr) {
Expand Down
Loading