Skip to content

Commit a615ee0

Browse files
committed
feat: electra state machine updates
* fix: active validator * chore: remove stale submodule * fix: require credential proofs after latest checkpoint timestamp * fix: integration tests * chore: format * chore: test feat: eigenpod withdrawal updates (#59) * fix: active validator * chore: remove stale submodule * fix: integration tests * chore: format * chore: update * chore: minor updates
1 parent 7ecc83c commit a615ee0

File tree

7 files changed

+87
-8
lines changed

7 files changed

+87
-8
lines changed

CHANGELOG/CHANGELOG-1.6.1.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# v1.6.1 Moocow Updates
2+
3+
## Release Manager
4+
5+
@ypatil12
6+
7+
## Highlights
8+
9+
🔧 **Improvements**
10+
- Make `EigenPod.validatorStatus` public to use internally as a helper
11+
- Update the `EigenPod.verifyWithdrawalCredentials` function to only accept `beaconTimestamps` that are after the `latestCheckpointTimestamp`. This enables the eigenpod state machine to be easier to be reasoned about
12+
- Update the `EigenPod.requestWithdrawal` function to ensure that validators are pointed to the pod, matching the behavior of `requestConsolidation`
13+
14+
## Changelog
15+
16+
TODO

docs/core/EigenPod.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ A withdrawal credential proof uses a validator's [`ValidatorIndex`][custom-types
117117
* `exit_epoch`: Initially set to `type(uint64).max`, this value is updated when a validator initiates exit from the beacon chain. **This method requires that a validator has not initiated an exit from the beacon chain.**
118118
* If a validator has been exited prior to calling `verifyWithdrawalCredentials`, their ETH can be accounted for, awarded shares, and/or withdrawn via the checkpoint system (see [Checkpointing Validators](#checkpointing-validators)).
119119

120-
_Note that it is not required to verify your validator's withdrawal credentials_, unless you want to receive shares for ETH on the beacon chain. You may choose to use your `EigenPod` without verifying withdrawal credentials; you will still be able to withdraw yield (or receive shares for yield) via the [checkpoint system](#checkpointing-validators).
120+
_Note that it is not required to verify your validator's withdrawal credentials_, unless you want to receive shares for ETH on the beacon chain. You may choose to use your `EigenPod` without verifying withdrawal credentials; you will still be able to withdraw yield (or receive shares for yield) via the [checkpoint system](#checkpointing-validators). To account for ETH held on the beacon chain and to make execution layer partial withdrawal or full exits, this function *MUST* be called.
121121

122122
*Effects*:
123123
* For each set of unique verified withdrawal credentials:
@@ -135,6 +135,7 @@ _Note that it is not required to verify your validator's withdrawal credentials_
135135
* Input array lengths MUST be equal
136136
* `beaconTimestamp`:
137137
* MUST be greater than `currentCheckpointTimestamp`
138+
* MUST be greater than `latestCheckpointTimestamp`
138139
* MUST be queryable via the [EIP-4788 oracle][eip-4788]. Generally, this means `beaconTimestamp` corresponds to a valid beacon block created within the last 8192 blocks (~27 hours).
139140
* `stateRootProof` MUST verify a `beaconStateRoot` against the `beaconBlockRoot` returned from the EIP-4788 oracle
140141
* For each validator:
@@ -411,6 +412,7 @@ This method allows the pod owner or proof submitter to submit validator withdraw
411412
* "Partial withdrawals" will exit a portion of a validator's balance from the beacon chain, down to 32 ETH. Any amount requested that would bring a validator's balance below 32 ETH is ignored.
412413

413414
In order to initiate a withdrawal request:
415+
* The [`verifyWithdrawalCredentials`](#verifywithdrawalcredentials) function must be called to prove the validator exists within the pod
414416
* The predeploy requires a fee for each request. The current fee for the block can be queried using `getWithdrawalRequestFee`. This should be multiplied for each request in the passed-in `requests` array and provided as `msg.value`. The predeploy updates its fee each block depending on how many withdrawal requests are queued vs how many are processed.
415417
* Note that any unused fee is transferred back to `msg.sender` at the end of this method.
416418
* For partial withdrawals, note that the beacon chain will only process these if the validator has 0x02 withdrawal credentials.
@@ -430,7 +432,8 @@ Note that the beacon chain may "skip" a withdrawal request for many reasons. Thi
430432
* Pause status MUST NOT be set: `PAUSED_WITHDRAWAL_REQUESTS`
431433
* `msg.value` MUST be at least `getWithdrawalRequestFee() * requests.length`
432434
* For each `request` in `requests`:
433-
* `request.pubkey` MUST have a length of 48
435+
* The validator MUST have been proven to the pod
436+
* `request.pubkey` MUST correspond to a validator whose withdrawal credentials are proven to point at the pod (`VALIDATOR_STATUS.ACTIVE`)
434437
* If excess `msg.value` was provided, the transfer of the excess back to `msg.sender` MUST succeed.
435438

436439
---

src/contracts/interfaces/IEigenPod.sol

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ interface IEigenPodErrors {
7676
error MsgValueNot32ETH();
7777
/// @dev Thrown when provided `beaconTimestamp` is too far in the past.
7878
error BeaconTimestampTooFarInPast();
79+
/// @dev Thrown when provided `beaconTimestamp` is before the last checkpoint
80+
error BeaconTimestampBeforeLatestCheckpoint();
7981
/// @dev Thrown when the pectraForkTimestamp returned from the EigenPodManager is zero
8082
error ForkTimestampZero();
8183
}

src/contracts/pods/EigenPod.sol

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ contract EigenPod is
225225
// on an existing checkpoint.
226226
require(beaconTimestamp > currentCheckpointTimestamp, BeaconTimestampTooFarInPast());
227227

228+
// For sanity, we want to ensure that a newly-verified validator cannot be proven against state
229+
// that has already been checkpointed. This check makes the state transitions easier to reason about.
230+
require(beaconTimestamp > lastCheckpointTimestamp, BeaconTimestampBeforeLatestCheckpoint());
231+
228232
// Verify passed-in `beaconStateRoot` against the beacon block root
229233
// forgefmt: disable-next-item
230234
BeaconChainProofs.verifyStateRoot({
@@ -321,8 +325,7 @@ contract EigenPod is
321325
// Ensure target has verified withdrawal credentials pointed at this pod
322326
bytes32 sourcePubkeyHash = _calcPubkeyHash(request.srcPubkey);
323327
bytes32 targetPubkeyHash = _calcPubkeyHash(request.targetPubkey);
324-
ValidatorInfo memory target = validatorPubkeyHashToInfo(targetPubkeyHash);
325-
require(target.status == VALIDATOR_STATUS.ACTIVE, ValidatorNotActiveInPod());
328+
require(validatorStatus(targetPubkeyHash) == VALIDATOR_STATUS.ACTIVE, ValidatorNotActiveInPod());
326329

327330
// Call the predeploy
328331
bytes memory callData = bytes.concat(request.srcPubkey, request.targetPubkey);
@@ -353,6 +356,9 @@ contract EigenPod is
353356
WithdrawalRequest calldata request = requests[i];
354357
bytes32 pubkeyHash = _calcPubkeyHash(request.pubkey);
355358

359+
// Ensure validator has verified withdrawal credentials pointed at this pod
360+
require(validatorStatus(pubkeyHash) == VALIDATOR_STATUS.ACTIVE, ValidatorNotActiveInPod());
361+
356362
// Call the predeploy
357363
bytes memory callData = abi.encodePacked(request.pubkey, request.amountGwei);
358364
(bool ok,) = WITHDRAWAL_REQUEST_ADDRESS.call{value: fee}(callData);
@@ -736,7 +742,7 @@ contract EigenPod is
736742
/// @inheritdoc IEigenPod
737743
function validatorStatus(
738744
bytes32 pubkeyHash
739-
) external view returns (VALIDATOR_STATUS) {
745+
) public view returns (VALIDATOR_STATUS) {
740746
return _validatorPubkeyHashToInfo[pubkeyHash].status;
741747
}
742748

src/test/integration/tests/DualSlashing.t.sol

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,10 @@ contract Integration_DualSlashing_FullSlashes is Integration_DualSlashing_Base {
327327
);
328328

329329
// 11. Exit remaining validators & checkpoint
330-
staker.exitValidators(newValidators);
330+
// Exit validators directly on beacon chain
331+
for (uint i = 0; i < newValidators.length; ++i) {
332+
beaconChain.exitValidator(newValidators[i]);
333+
}
331334
beaconChain.advanceEpoch_NoRewards();
332335
staker.startCheckpoint();
333336
check_StartCheckpoint_WithPodBalance_State(staker, addedBeaconBalanceGwei);

src/test/integration/tests/eigenpod/VerifyWC_StartCP_CompleteCP.t.sol

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,13 @@ contract Integration_VerifyWC_StartCP_CompleteCP is IntegrationCheckUtils {
213213
(User staker,,) = _newRandomStaker();
214214

215215
(uint40[] memory validators,,) = staker.startValidators();
216-
staker.exitValidators(validators);
216+
217+
// We use the beacon chain mock directly instead of the user contract since the
218+
// user contract uses the precompile to exit validators. The pod expects validators
219+
// to be proven to use the precompile.
220+
for (uint i = 0; i < validators.length; i++) {
221+
beaconChain.exitValidator(validators[i]);
222+
}
217223
beaconChain.advanceEpoch_NoRewards();
218224

219225
cheats.expectRevert(IEigenPodErrors.ValidatorIsExitingBeaconChain.selector);

src/test/unit/EigenPodUnit.t.sol

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,20 @@ contract EigenPodUnitTests_verifyWithdrawalCredentials is EigenPodUnitTests, Pro
665665
staker.verifyWithdrawalCredentials(validators);
666666
}
667667

668+
/// @notice beaconTimestamp must be after the last checkpoint
669+
function test_revert_beaconTimestampBeforeCheckpoint() public {
670+
(EigenPodUser staker,) = _newEigenPodStaker({rand: 0});
671+
(uint40[] memory validators,,) = staker.startValidators();
672+
673+
// Start a checkpoint so `currentCheckpointTimestamp` is nonzero
674+
staker.startCheckpoint();
675+
assertGt(staker.pod().lastCheckpointTimestamp(), 0, "lastCheckpointTimestamp should be nonzero");
676+
677+
// Try to verify withdrawal credentials at the current block, right at the checkpoint timestamp
678+
cheats.expectRevert(IEigenPodErrors.BeaconTimestampBeforeLatestCheckpoint.selector);
679+
staker.verifyWithdrawalCredentials(validators);
680+
}
681+
668682
/// @notice Check for revert on input array mismatch lengths
669683
function testFuzz_revert_inputArrayLengthsMismatch(uint rand) public {
670684
(EigenPodUser staker,) = _newEigenPodStaker({rand: rand});
@@ -774,7 +788,11 @@ contract EigenPodUnitTests_verifyWithdrawalCredentials is EigenPodUnitTests, Pro
774788
(uint40[] memory validators,,) = staker.startValidators();
775789

776790
// Exit validators from beacon chain and withdraw to pod
777-
staker.exitValidators(validators);
791+
// We use the beacon chain mock directly instead of the user contract since the
792+
// user contract uses the precompile to exit validators.
793+
for (uint i = 0; i < validators.length; i++) {
794+
beaconChain.exitValidator(validators[i]);
795+
}
778796
beaconChain.advanceEpoch();
779797

780798
// now that validators are exited, ensure we can't verify them
@@ -1747,6 +1765,31 @@ contract EigenPodUnitTests_PectraFeatures is EigenPodUnitTests {
17471765
pod.requestWithdrawal(new WithdrawalRequest[](0));
17481766
}
17491767

1768+
/// @notice Revert when the validator is not active in the pod
1769+
function testFuzz_revert_validatorNotActiveInPod() public {
1770+
(EigenPodUser staker,) = _newEigenPodStaker(64 ether);
1771+
EigenPod pod = staker.pod();
1772+
address podOwner = pod.podOwner();
1773+
1774+
(uint40[] memory validators,,) = staker.startValidators();
1775+
staker.verifyWithdrawalCredentials(validators);
1776+
1777+
WithdrawalRequest[] memory requests = new WithdrawalRequest[](validators.length);
1778+
for (uint i = 0; i < validators.length; i++) {
1779+
bytes memory pubkey = validators[i].toPubkey();
1780+
// Mutate pubkey to be invalid
1781+
pubkey[0] = bytes1(uint8(pubkey[0]) + 1);
1782+
requests[i] = WithdrawalRequest({pubkey: pubkey, amountGwei: 0});
1783+
}
1784+
1785+
uint fee = pod.getWithdrawalRequestFee() * requests.length;
1786+
1787+
cheats.deal(podOwner, address(podOwner).balance + fee);
1788+
cheats.prank(podOwner);
1789+
cheats.expectRevert(IEigenPodErrors.ValidatorNotActiveInPod.selector);
1790+
pod.requestWithdrawal{value: fee}(requests);
1791+
}
1792+
17501793
/// @notice Revert when the caller does not supply enough msg.value for the fee
17511794
function testFuzz_revert_insufficientFunds(uint randConsolidations, uint randWithdrawals) public {
17521795
randConsolidations = bound(randConsolidations, 2, 10);

0 commit comments

Comments
 (0)