Skip to content

Commit 396c94f

Browse files
Merge pull request #373 from etherfi-protocol/pankaj/fix/improve-stETH-claiming-script
refactor: Enhance stETH management scripts
2 parents be0efa7 + 2851bd0 commit 396c94f

7 files changed

Lines changed: 232 additions & 55 deletions

File tree

script/operations/steth-management/AutomateStEthWithdrawals.s.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ interface ILidoWithdrawalQueue {
1919
}
2020

2121
// Full withdrawal:
22-
// FULL_WITHDRAWAL=true forge script script/operations/steth-claim-withdrawals/AutomateStEthWithdrawals.s.sol --fork-url $MAINNET_RPC_URL -vvvv
22+
// FULL_WITHDRAWAL=true forge script script/operations/steth-management/AutomateStEthWithdrawals.s.sol --fork-url $MAINNET_RPC_URL -vvvv
2323
//
2424
// Partial withdrawal (amount in ether):
25-
// AMOUNT=100 forge script script/operations/steth-claim-withdrawals/AutomateStEthWithdrawals.s.sol --fork-url $MAINNET_RPC_URL -vvvv
25+
// AMOUNT=100 forge script script/operations/steth-management/AutomateStEthWithdrawals.s.sol --fork-url $MAINNET_RPC_URL -vvvv
2626

2727
contract AutomateStEthWithdrawals is Script, Deployed {
2828

script/operations/steth-management/ClaimStEthWithdrawals.s.sol

Lines changed: 66 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,58 +5,75 @@ import "forge-std/Script.sol";
55
import "forge-std/console2.sol";
66
import {Deployed} from "../../deploys/Deployed.s.sol";
77
import {EtherFiRestaker} from "../../../src/EtherFiRestaker.sol";
8-
// import {ILidoWithdrawalQueue} from "../../../src/interfaces/ILiquifier.sol";
8+
import {ILidoWithdrawalQueue} from "../../../src/interfaces/ILiquifier.sol";
99

10-
interface ILidoWithdrawalQueue {
11-
function getLastFinalizedRequestId() external view returns (uint256);
12-
function getLastCheckpointIndex() external view returns (uint256);
13-
function findCheckpointHints(uint256[] calldata _requestIds, uint256 _firstIndex, uint256 _lastIndex) external view returns (uint256[] memory hintIds);
14-
}
15-
16-
// forge script script/operations/steth-claim-withdrawals/ClaimStEthWithdrawals.s.sol --fork-url $MAINNET_RPC_URL -vvvv
10+
// forge script script/operations/steth-management/ClaimStEthWithdrawals.s.sol --fork-url $MAINNET_RPC_URL -vvvv
1711

1812
contract ClaimStEthWithdrawals is Script, Deployed {
1913

2014
EtherFiRestaker constant etherFiRestaker = EtherFiRestaker(payable(ETHERFI_RESTAKER));
2115

2216
function run() external {
23-
uint256 startId = 113785; // Set this to the first request you want to claim
24-
uint256 endId = 113863; // Set this to the last request you want to claim
25-
2617
ILidoWithdrawalQueue lidoWithdrawalQueue = ILidoWithdrawalQueue(address(etherFiRestaker.lidoWithdrawalQueue()));
27-
console2.log("LidoWithdrawalQueue:", address(lidoWithdrawalQueue));
18+
console2.log("EtherFiRestaker: ", address(etherFiRestaker));
19+
console2.log("LidoWithdrawalQueue: ", address(lidoWithdrawalQueue));
20+
21+
// 1. Fetch all withdrawal request IDs owned by the restaker
22+
uint256[] memory allRequestIds = lidoWithdrawalQueue.getWithdrawalRequests(address(etherFiRestaker));
23+
console2.log("Total pending requests for restaker:", allRequestIds.length);
24+
25+
if (allRequestIds.length == 0) {
26+
console2.log("No pending withdrawal requests found. Nothing to claim.");
27+
return;
28+
}
29+
30+
// 2. Get statuses for all requests
31+
ILidoWithdrawalQueue.WithdrawalRequestStatus[] memory statuses =
32+
lidoWithdrawalQueue.getWithdrawalStatus(allRequestIds);
2833

29-
// Cap endId to the last finalized request
30-
uint256 lastFinalizedId = ILidoWithdrawalQueue(address(lidoWithdrawalQueue)).getLastFinalizedRequestId();
31-
console2.log("Last finalized request ID:", lastFinalizedId);
34+
// 3. Filter to finalized & unclaimed requests
35+
uint256 claimableCount = 0;
36+
for (uint256 i = 0; i < statuses.length; i++) {
37+
if (statuses[i].isFinalized && !statuses[i].isClaimed) {
38+
claimableCount++;
39+
}
40+
}
41+
42+
console2.log("Claimable (finalized & unclaimed):", claimableCount);
43+
44+
if (claimableCount == 0) {
45+
console2.log("No claimable requests at this time. Nothing to claim.");
46+
return;
47+
}
3248

33-
if (endId > lastFinalizedId) {
34-
console2.log("WARNING: endId", endId, "exceeds last finalized ID, capping to", lastFinalizedId);
35-
endId = lastFinalizedId;
49+
uint256[] memory requestIds = new uint256[](claimableCount);
50+
uint256 idx = 0;
51+
for (uint256 i = 0; i < allRequestIds.length; i++) {
52+
if (statuses[i].isFinalized && !statuses[i].isClaimed) {
53+
requestIds[idx] = allRequestIds[i];
54+
idx++;
55+
}
3656
}
37-
require(startId <= endId, "No finalized requests in range");
3857

39-
uint256 count = endId - startId + 1;
40-
console2.log("Claiming", count, "requests:", startId);
41-
console2.log(" to", endId);
58+
_sortAscending(requestIds);
4259

43-
uint256[] memory requestIds = new uint256[](count);
44-
for (uint256 i = 0; i < count; i++) {
45-
requestIds[i] = startId + i;
60+
console2.log("Claiming request IDs:");
61+
for (uint256 i = 0; i < requestIds.length; i++) {
62+
console2.log(" ", requestIds[i]);
4663
}
4764

48-
// Get checkpoint hints
49-
uint256 lastCheckpointIndex = ILidoWithdrawalQueue(address(lidoWithdrawalQueue)).getLastCheckpointIndex();
65+
// 4. Get checkpoint hints for the claimable requests
66+
uint256 lastCheckpointIndex = lidoWithdrawalQueue.getLastCheckpointIndex();
5067
console2.log("Last checkpoint index:", lastCheckpointIndex);
5168

52-
uint256[] memory hints = ILidoWithdrawalQueue(address(lidoWithdrawalQueue)).findCheckpointHints(requestIds, 1, lastCheckpointIndex);
69+
uint256[] memory hints = lidoWithdrawalQueue.findCheckpointHints(requestIds, 1, lastCheckpointIndex);
5370

54-
console2.log("Hints found for", hints.length, "requests");
71+
console2.log("Hints found for", hints.length, "requests:");
5572
for (uint256 i = 0; i < hints.length; i++) {
5673
console2.log(" requestId:", requestIds[i], "hint:", hints[i]);
5774
}
5875

59-
// Encode the calldata
76+
// 5. Encode calldata for multisig / safe submission
6077
bytes memory callData = abi.encodeWithSelector(
6178
EtherFiRestaker.stEthClaimWithdrawals.selector,
6279
requestIds,
@@ -68,16 +85,29 @@ contract ClaimStEthWithdrawals is Script, Deployed {
6885
console2.log("Target:", address(etherFiRestaker));
6986
console2.logBytes(callData);
7087

71-
// Simulate the transaction on fork as operating admin
88+
// 6. Simulate on fork as operating admin
7289
console2.log("");
7390
console2.log("=== Simulating on fork ===");
74-
uint256 liquidityPoolbalanceBefore = LIQUIDITY_POOL.balance;
75-
console2.log("LiquidityPool balance before:", uint256(liquidityPoolbalanceBefore) / 1e18);
91+
uint256 lpBalanceBefore = LIQUIDITY_POOL.balance;
92+
console2.log("LiquidityPool balance before:", lpBalanceBefore / 1e18);
7693
vm.prank(ETHERFI_OPERATING_ADMIN);
7794
etherFiRestaker.stEthClaimWithdrawals(requestIds, hints);
78-
uint256 liquidityPoolbalanceAfter = LIQUIDITY_POOL.balance;
79-
console2.log("LiquidityPool balance after:", uint256(liquidityPoolbalanceAfter) / 1e18);
80-
console2.log("ETH claimed:", (liquidityPoolbalanceAfter - liquidityPoolbalanceBefore) / 1e18);
95+
uint256 lpBalanceAfter = LIQUIDITY_POOL.balance;
96+
console2.log("LiquidityPool balance after:", lpBalanceAfter / 1e18);
97+
console2.log("ETH claimed:", (lpBalanceAfter - lpBalanceBefore) / 1e18);
8198
console2.log("Simulation successful");
8299
}
100+
101+
function _sortAscending(uint256[] memory arr) internal pure {
102+
uint256 length = arr.length;
103+
for (uint256 i = 1; i < length; i++) {
104+
uint256 key = arr[i];
105+
uint256 j = i;
106+
while (j > 0 && arr[j - 1] > key) {
107+
arr[j] = arr[j - 1];
108+
j--;
109+
}
110+
arr[j] = key;
111+
}
112+
}
83113
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.27;
3+
4+
import "forge-std/Script.sol";
5+
import "forge-std/console2.sol";
6+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
7+
import {Deployed} from "../../deploys/Deployed.s.sol";
8+
import {EtherFiRestaker} from "../../../src/EtherFiRestaker.sol";
9+
import {IDelegationManager} from "../../../src/eigenlayer-interfaces/IDelegationManager.sol";
10+
11+
// Complete all pending EigenLayer stETH withdrawal queues on the restaker:
12+
// forge script script/operations/steth-management/CompleteQueuedWithdrawalsStETH.s.sol --fork-url $MAINNET_RPC_URL -vvvv
13+
14+
contract CompleteQueuedWithdrawalsStETH is Script, Deployed {
15+
16+
EtherFiRestaker constant etherFiRestaker = EtherFiRestaker(payable(ETHERFI_RESTAKER));
17+
18+
function run() external {
19+
IDelegationManager delegationManager = etherFiRestaker.eigenLayerDelegationManager();
20+
address lido = address(etherFiRestaker.lido());
21+
uint32 delayBlocks = delegationManager.minWithdrawalDelayBlocks();
22+
23+
console2.log("=== EtherFi Restaker: Complete Queued stETH Withdrawals ===");
24+
console2.log("Restaker:", address(etherFiRestaker));
25+
console2.log("Current block:", block.number);
26+
console2.log("Min withdrawal delay blocks:", delayBlocks);
27+
console2.log("");
28+
29+
// Fetch all pending withdrawal roots tracked by the restaker
30+
bytes32[] memory allRoots = etherFiRestaker.pendingWithdrawalRoots();
31+
console2.log("Total pending withdrawal roots:", allRoots.length);
32+
require(allRoots.length > 0, "No pending withdrawal roots");
33+
34+
// Filter for completable roots (past the delay)
35+
uint256 completableCount = 0;
36+
bool[] memory isCompletable = new bool[](allRoots.length);
37+
38+
for (uint256 i = 0; i < allRoots.length; i++) {
39+
(IDelegationManager.Withdrawal memory w,) = delegationManager.getQueuedWithdrawal(allRoots[i]);
40+
41+
bool pastDelay = block.number >= uint256(w.startBlock) + uint256(delayBlocks);
42+
isCompletable[i] = pastDelay;
43+
44+
console2.log("---");
45+
console2.log(" root:");
46+
console2.logBytes32(allRoots[i]);
47+
console2.log(" startBlock:", w.startBlock);
48+
console2.log(" completableAtBlock:", uint256(w.startBlock) + uint256(delayBlocks));
49+
console2.log(" ready:", pastDelay ? "YES" : "NO");
50+
51+
if (pastDelay) completableCount++;
52+
}
53+
54+
console2.log("");
55+
console2.log("Completable withdrawals:", completableCount, "of", allRoots.length);
56+
require(completableCount > 0, "No withdrawals ready to complete yet");
57+
58+
// Build arrays for only the completable withdrawals
59+
IDelegationManager.Withdrawal[] memory withdrawals = new IDelegationManager.Withdrawal[](completableCount);
60+
IERC20[][] memory tokens = new IERC20[][](completableCount);
61+
62+
uint256 idx = 0;
63+
for (uint256 i = 0; i < allRoots.length; i++) {
64+
if (!isCompletable[i]) continue;
65+
66+
(IDelegationManager.Withdrawal memory w,) = delegationManager.getQueuedWithdrawal(allRoots[i]);
67+
withdrawals[idx] = w;
68+
69+
tokens[idx] = new IERC20[](w.strategies.length);
70+
for (uint256 j = 0; j < w.strategies.length; j++) {
71+
tokens[idx][j] = w.strategies[j].underlyingToken();
72+
}
73+
idx++;
74+
}
75+
76+
// Log calldata for Gnosis Safe
77+
bytes memory callData = abi.encodeWithSelector(
78+
EtherFiRestaker.completeQueuedWithdrawals.selector,
79+
withdrawals,
80+
tokens
81+
);
82+
console2.log("");
83+
console2.log("=== completeQueuedWithdrawals calldata ===");
84+
console2.log("Target:", address(etherFiRestaker));
85+
console2.logBytes(callData);
86+
87+
// Simulate the completion on fork
88+
console2.log("");
89+
console2.log("=== Simulating completion on fork ===");
90+
uint256 stEthBefore = IERC20(lido).balanceOf(address(etherFiRestaker));
91+
92+
vm.prank(ETHERFI_OPERATING_ADMIN);
93+
etherFiRestaker.completeQueuedWithdrawals(withdrawals, tokens);
94+
95+
uint256 stEthAfter = IERC20(lido).balanceOf(address(etherFiRestaker));
96+
console2.log("stETH received by restaker:", stEthAfter - stEthBefore);
97+
console2.log("Remaining pending roots:", etherFiRestaker.pendingWithdrawalRoots().length);
98+
console2.log("Remaining restaked stETH:", etherFiRestaker.getRestakedAmount(lido));
99+
console2.log("Simulation successful");
100+
101+
// bytes memory callData2 = abi.encodeWithSignature(
102+
// "stEthRequestWithdrawal(uint256)",
103+
// 50000 ether
104+
// );
105+
// console2.log("");
106+
// console2.log("=== stEthRequestWithdrawal calldata ===");
107+
// console2.log("Target:", address(etherFiRestaker));
108+
// console2.logBytes(callData2);
109+
110+
// vm.prank(ETHERFI_OPERATING_ADMIN);
111+
// etherFiRestaker.stEthRequestWithdrawal(50000 ether);
112+
}
113+
}

script/operations/steth-management/UnrestakeStEth.s.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import {EtherFiRestaker} from "../../../src/EtherFiRestaker.sol";
99
import {IDelegationManager} from "../../../src/eigenlayer-interfaces/IDelegationManager.sol";
1010

1111
// Full un-restake (all stETH restaked in EigenLayer):
12-
// FULL_WITHDRAWAL=true forge script script/operations/steth-claim-withdrawals/UnrestakeStEth.s.sol --fork-url $MAINNET_RPC_URL -vvvv
12+
// FULL_WITHDRAWAL=true forge script script/operations/steth-management/UnrestakeStEth.s.sol --fork-url $MAINNET_RPC_URL -vvvv
1313
//
1414
// Partial un-restake (amount in ether):
15-
// AMOUNT=100 forge script script/operations/steth-claim-withdrawals/UnrestakeStEth.s.sol --fork-url $MAINNET_RPC_URL -vvvv
15+
// AMOUNT=100 forge script script/operations/steth-management/UnrestakeStEth.s.sol --fork-url $MAINNET_RPC_URL -vvvv
1616

1717
contract UnrestakeStEth is Script, Deployed {
1818

test/integration-tests/Handle-Remainder-Shares.t.sol

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,16 +198,18 @@ contract HandleRemainderSharesIntegrationTest is TestSetup, Deployed {
198198
roleRegistryInstance.grantRole(withdrawRequestNFTInstance.IMPLICIT_FEE_CLAIMER_ROLE(), alice);
199199
vm.stopPrank();
200200

201-
uint256 treasuryBalanceBefore = eETHInstance.balanceOf(buybackWallet);
201+
uint256 nominalToTreasury = Math.mulDiv(remainderAmount, splitRatios[i], 10000);
202+
uint256 expectedSharesToTreasury = liquidityPoolInstance.sharesForAmount(nominalToTreasury);
203+
204+
uint256 treasurySharesBefore = eETHInstance.shares(buybackWallet);
202205

203206
vm.prank(alice);
204207
withdrawRequestNFTInstance.handleRemainder(remainderAmount);
205208

206-
uint256 treasuryBalanceAfter = eETHInstance.balanceOf(buybackWallet);
207-
uint256 expectedToTreasury = Math.mulDiv(remainderAmount, splitRatios[i], 10000);
209+
uint256 treasurySharesAfter = eETHInstance.shares(buybackWallet);
208210

209-
assertApproxEqAbs(treasuryBalanceAfter - treasuryBalanceBefore, expectedToTreasury, 1e14,
210-
string(abi.encodePacked("Treasury should receive correct portion for ratio ", vm.toString(splitRatios[i]))));
211+
assertApproxEqAbs(treasurySharesAfter - treasurySharesBefore, expectedSharesToTreasury, 10,
212+
string(abi.encodePacked("Treasury should receive correct shares for ratio ", vm.toString(splitRatios[i]))));
211213
}
212214
}
213215
}

test/integration-tests/Validator-Flows.t.sol

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ contract ValidatorFlowsIntegrationTest is TestSetup, Deployed {
1919

2020
/// @dev Advances the admin's lastHandledReportRefSlot to match the oracle's lastPublishedReportRefSlot.
2121
function _syncOracleReportState() internal {
22+
// Step A: sync admin to oracle so submitReport doesn't fail with
23+
// "Last published report is not handled yet".
2224
uint32 lastPublished = etherFiOracleInstance.lastPublishedReportRefSlot();
2325
uint32 lastHandled = etherFiAdminInstance.lastHandledReportRefSlot();
2426

@@ -34,6 +36,18 @@ contract ValidatorFlowsIntegrationTest is TestSetup, Deployed {
3436
val |= uint256(lastPublishedBlock) << 32;
3537
vm.store(address(etherFiAdminInstance), bytes32(uint256(209)), bytes32(val));
3638
}
39+
40+
// Step B: unconditionally reset operator submissions by removing and re-adding each one.
41+
// addCommitteeMember() resets CommitteeMemberState to
42+
// (registered=true, enabled=true, lastReportRefSlot=0, numReports=0), clearing any stale
43+
// submission from mainnet without adding new committee members.
44+
address oracleOwner = etherFiOracleInstance.owner();
45+
vm.startPrank(oracleOwner);
46+
etherFiOracleInstance.removeCommitteeMember(AVS_OPERATOR_1);
47+
etherFiOracleInstance.addCommitteeMember(AVS_OPERATOR_1);
48+
etherFiOracleInstance.removeCommitteeMember(AVS_OPERATOR_2);
49+
etherFiOracleInstance.addCommitteeMember(AVS_OPERATOR_2);
50+
vm.stopPrank();
3751
}
3852

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

test/integration-tests/Withdraw.t.sol

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,44 @@ contract WithdrawIntegrationTest is TestSetup, Deployed {
1919
_syncOracleReportState();
2020
}
2121

22-
/// @dev Advances the admin's lastHandledReportRefSlot to match the oracle's lastPublishedReportRefSlot.
23-
/// On mainnet fork there may be a published report the admin hasn't processed yet.
24-
/// We advance the admin forward (not rewind the oracle) so that slotForNextReport()
25-
/// returns the correct next slot and committee members can still submit new reports.
22+
/// @dev Syncs oracle/admin state so AVS_OPERATOR_1 and AVS_OPERATOR_2 can submit reports.
23+
///
24+
/// Two conditions can block report submission on a mainnet fork:
25+
///
26+
/// (A) Admin hasn't processed the latest published report yet:
27+
/// shouldSubmitReport() requires lastPublishedReportRefSlot == lastHandledReportRefSlot.
28+
/// Fix: advance admin's lastHandledReportRefSlot to match the oracle.
29+
///
30+
/// (B) Operators already submitted for slotForNextReport() (quorum not reached on mainnet):
31+
/// shouldSubmitReport() returns false when lastReportRefSlot >= slotForNextReport().
32+
/// Fix: remove and re-add each operator via the oracle owner. addCommitteeMember()
33+
/// resets CommitteeMemberState to (registered=true, enabled=true, lastReportRefSlot=0),
34+
/// clearing the stale submission without adding new committee members.
2635
function _syncOracleReportState() internal {
36+
// Step A: sync admin to oracle
2737
uint32 lastPublished = etherFiOracleInstance.lastPublishedReportRefSlot();
28-
uint32 lastHandled = etherFiAdminInstance.lastHandledReportRefSlot();
29-
38+
uint32 lastHandled = etherFiAdminInstance.lastHandledReportRefSlot();
3039
if (lastPublished != lastHandled) {
3140
uint32 lastPublishedBlock = etherFiOracleInstance.lastPublishedReportRefBlock();
32-
33-
// EtherFiAdmin slot 209 packs: lastHandledReportRefSlot (4B @ offset 0) +
34-
// lastHandledReportRefBlock (4B @ offset 4) + other fields in higher bytes
3541
bytes32 slot209 = vm.load(address(etherFiAdminInstance), bytes32(uint256(209)));
3642
uint256 val = uint256(slot209);
37-
val &= ~uint256(0xFFFFFFFFFFFFFFFF); // clear low 64 bits (both uint32 fields)
43+
val &= ~uint256(0xFFFFFFFFFFFFFFFF); // clear bits 0-63
3844
val |= uint256(lastPublished);
3945
val |= uint256(lastPublishedBlock) << 32;
4046
vm.store(address(etherFiAdminInstance), bytes32(uint256(209)), bytes32(val));
4147
}
48+
49+
// Step B: unconditionally reset operator submissions by removing and re-adding each one.
50+
// addCommitteeMember() resets CommitteeMemberState to
51+
// (registered=true, enabled=true, lastReportRefSlot=0, numReports=0), clearing any stale
52+
// submission from mainnet without adding new committee members.
53+
address oracleOwner = etherFiOracleInstance.owner();
54+
vm.startPrank(oracleOwner);
55+
etherFiOracleInstance.removeCommitteeMember(AVS_OPERATOR_1);
56+
etherFiOracleInstance.addCommitteeMember(AVS_OPERATOR_1);
57+
etherFiOracleInstance.removeCommitteeMember(AVS_OPERATOR_2);
58+
etherFiOracleInstance.addCommitteeMember(AVS_OPERATOR_2);
59+
vm.stopPrank();
4260
}
4361

4462
function test_Withdraw_EtherFiRedemptionManager_redeemEEth() public {

0 commit comments

Comments
 (0)