## EigenPod Redesign - Checkpoint Proofs [TOC] #### References * Ongoing contracts work: [`eigenlayer-contracts/#515`](https://github.com/Layr-Labs/eigenlayer-contracts/pull/515) * [eip-4788](https://eips.ethereum.org/EIPS/eip-4788) * Lighthouse block and epoch processing: * [`per_block_processing`](https://github.com/sigp/lighthouse/blob/3058b96f2560f1da04ada4f9d8ba8e5651794ff6/consensus/state_processing/src/per_block_processing.rs#L100) * [`per_epoch_processing`](https://github.com/sigp/lighthouse/blob/3058b96f2560f1da04ada4f9d8ba8e5651794ff6/consensus/state_processing/src/per_epoch_processing/capella.rs#L21) * [`apply_blocks`](https://github.com/sigp/lighthouse/blob/3058b96f2560f1da04ada4f9d8ba8e5651794ff6/consensus/state_processing/src/block_replayer.rs#L223) ### The Problem with Pods The primary technical problem with EigenPod beacon state proofs is using them to determine where ETH is with any degree of accuracy. The reason we require state proofs in the first place is to ensure that *if we award shares, they are guaranteed to correspond 1:1 to ETH that will eventually flow through the pod.* * This property is important for payments, dictating the amount a staker earns for the shares they delegate. * This property is even more important for slashing, as it provides a *guaranteed downside* if a staker misbehaves - their shares can be slashed, and this directly represents the ETH they stand to lose. For this reason, the **ultimate goal** of the EigenPod proof system is to establish the current whereabouts of the assets backing the shares being awarded. Ideally, the proof system should not be *ambiguous*. When we process a proof, we should definitively know two things: 1. How much ETH is backing the pod 2. Where the ETH is (on the beacon chain, or in the pod) As long as we can determine these things without ambiguity, we can safely award shares that we know to be backed. ### The M2 Proof System The M2 proofs struggle with ambiguity and have some accounting quirks that will make it harder to implement new features (especially slashing). To understand why, we need to understand what information our M2 system consumes - that is, *what we are actually proving.* Currently, we have three types of proofs: * Withdrawal credential proofs * Balance update proofs * Partial/Full withdrawal proofs Between these three proof types, we're mainly consuming two pieces of beacon chain info: *effective balances* and *withdrawals.* We're using this info to determine both: * how much ETH is backing the pod's shares * where the backing ETH is (beacon chain or pod) M2 uses this information to award or remove shares accordingly. #### Effective Balance Withdrawal credential and balance update proofs consume a validator's `effective_balance`, located in the beacon chain's [`Validator` container](https://eth2book.info/capella/part3/containers/dependencies/#validator). The problem with effective balances is that they are only updated *every epoch,* rather than *every block.* This means that an effective balance proof may be up to 32 blocks stale - omitting validator balance changes like: * Proposer or attester slashings * Proposer rewards * Deposits * Partial or full withdrawals By relying solely on an effective balance proof, we do not know *exactly* how much ETH the validator currently has on the beacon chain. We also do not know if the validator has already withdrawn. #### Withdrawals Partial/full withdrawal proofs consume an `amount` found in the beacon chain's [`Withdrawal` container](https://eth2book.info/capella/part3/containers/dependencies/#withdrawal). They also compare the epoch the `Withdrawal` was created in with the `withdrawable_epoch` found in the [`Validator` container](https://eth2book.info/capella/part3/containers/dependencies/#validator). This acts as a heuristic to determine whether the `Withdrawal` is treated as a "partial" or a "full" withdrawal. The upside of consuming beacon chain `Withdrawals` is that by the time an EigenPod sees a `Withdrawal` proof, *the exact ETH amount proven is guaranteed to be in the pod.* However, there are several downsides to relying on beacon chain `Withdrawals`: * Beacon chain `Withdrawals` are created every block, and as of the Dencun upgrade, there are a maximum of 16 withdrawals processed per block. * These withdrawals may be either partial or full withdrawals; they live in the same queue. There is no way to distinguish between a partial and full withdrawal. * A `Validator's` `withdrawable_epoch` does not give a reliable indicator of full withdrawal - it only indicates that "at some point after `withdrawable_epoch`, the validator will be withdrawn." * Note, too, that the existence of a `Withdrawal` in a block *after* the validator's `withdrawable_epoch` DOES mean that the validator has fully withdrawn. However, there is no way to tell whether that specific `Withdrawal` is the entire amount withdrawn! #### Withdrawal Edge Cases - Scenario A single withdrawal proof in isolation tells you how much ETH moved to the pod, but tells you nothing about how much ETH is still on the beacon chain. This means that *we can't tell* if the withdrawal we're seeing is ALL the ETH we'll see from this validator, or if there is more on the way. This makes it hard to adjust shares based on withdrawal proofs, and leads to ambiguity and edge cases in M2 share accounting. Consider the following scenario: **0) I have a validator with 32 ETH on the beacon chain, and I've proven my validator's withdrawal credentials are pointed at my pod.** | ETH | Shares | | -------- | -------- | | 32 ETH on beacon chain | 32 ETH of shares | **1) Then, I exit one of my validators from the beacon chain. I *do not prove* this to my pod.** | ETH | Shares | | -------- | -------- | | - 0 ETH on beacon chain <br /> - 32 ETH in my pod | - 32 ETH of shares | **2) Next, I deposit 1 ETH to my exited validator.** This will be automatically withdrawn to my pod, but not immediately. For now, it's just recorded in my validator's `effective_balance`. | ETH | Shares | | -------- | -------- | | - 1 ETH on beacon chain <br /> - 32 ETH in my pod | - 32 ETH of shares | **3) Finally, I perform a balance update proof, showing my pod that my validator only has 1 ETH.** Because the last proven balance my pod saw was 32 ETH, this results in a share decrease of 31 ETH. | ETH | Shares | | -------- | -------- | | - 1 ETH on beacon chain <br /> - 32 ETH in my pod | - 1 ETH of shares | Ultimately, this allows a validator to misrepresent the amount of shares they have in their pod, which is especially dangerous in the context of slashing. An attacker could commit malicious behavior, then manipulate their beacon shares as described to reduce the number of shares that can be slashed. This carries little risk for the attacker, as the m2 proof system will still accept the missing withdrawal proof - so the attacker can reinstate their shares when the danger of slashing has passed. #### M2 Proofs - Conclusion Using beacon chain proofs, we cannot distinguish between full and partial withdrawals, and we cannot rely on withdrawal proofs to determine an order of events. In M2, we apply a heuristic to get a "best guess" on this, but this guess has some accounting quirks that lead to significant attack vectors for a future EigenLayer slashing release. *We will need to address this before releasing slashing.* --- ### The Checkpoint Proof System The checkpoint proof system is based around the concept of a pod's "active validator set." That is - a set of validators on the beacon chain that are known by the pod to have withdrawal credentials pointed at the pod. When you prove a validator's withdrawal credentials, that validator enters the pod's active validator set. When you show that a validator has exited, that validator leaves the pod's active validator set. The M2 system uses this state model already: * `verifyWithdrawalCredentials` sets `validatorInfo.status` to `ACTIVE`, and increases `activeValidatorCount` by 1 * When processing a full withdrawal, `verifyAndProcessWithdrawals` sets `validatorInfo.status` to `WITHDRAWN` and decreases `activeValidatorCount` by 1 A checkpoint proof combines a pod's active validator set with a snapshot of both beacon chain state *and* EigenPod state to unambiguously determine both *how much ETH is backing a pod* AND *where the ETH is.* #### What is a Checkpoint Proof? ```solidity struct Checkpoint { bytes32 beaconBlockRoot; // beacon block root of the block before the checkpoint is started uint256 podBalanceGwei; // pod balance in gwei when checkpoint starts, minus balance already backed by shares int256 balanceDeltasGwei; // total change in beacon balances, updated as checkpoint progresses uint256 proofsRemaining; // # of validators to prove before checkpoint is done } ``` A checkpoint proof has two steps: 1. The pod owner calls `startCheckpoint`. This takes a snapshot of beacon chain and EigenPod state: ```solidity uint256 podBalanceGwei = (address(this).balance / GWEI_TO_WEI) - withdrawableRestakedExecutionLayerGwei; // Create checkpoint using the previous block's root for proofs, and the current // `activeValidatorCount` as the number of checkpoint proofs needed to finalize // the checkpoint. Checkpoint memory checkpoint = Checkpoint({ beaconBlockRoot: _getParentBlockRoot(uint64(block.timestamp)), podBalanceGwei: podBalanceGwei, balanceDeltasGwei: 0, proofsRemaining: activeValidatorCount }); ``` 2. Via `verifyCheckpointProofs`, the pod owner (or anyone else) submits one balance proof for EACH validator in the `ACTIVE` state. This decreases the checkpoint's `proofsRemaining` and updates the checkpoint's `balanceDeltasGwei` with the difference since the last seen balance. ```solidity struct BalanceProof { bytes32 pubkeyHash; // pubkey of the validator being proven bytes32 balanceRoot; // the encoded balance of the validator bytes proof; // merkle proof of inclusion within the beacon state root } function verifyCheckpointProofs( BeaconChainProofs.StateRootProof calldata stateRootProof, BeaconChainProofs.BalanceProof[] calldata proofs ) external; ``` `verifyCheckpointProofs` automatically finalizes a checkpoint once enough proofs have been submitted. To finalize a checkpoint, the pod computes a total share delta and sends it to the `EigenPodManager`: ```solidity int256 totalShareDeltaWei = (int256(checkpoint.podBalanceGwei) + checkpoint.balanceDeltasGwei) * int256(GWEI_TO_WEI); // Add any native ETH in the pod to `withdrawableRestakedExecutionLayerGwei` // ... this amount can be withdrawn via the `DelegationManager` withdrawal queue withdrawableRestakedExecutionLayerGwei += uint64(checkpoint.podBalanceGwei); // Finalize the checkpoint lastFinalizedCheckpoint = currentCheckpointTimestamp; delete currentCheckpointTimestamp; delete currentCheckpoint; // Update pod owner's shares eigenPodManager.recordBeaconChainETHBalanceUpdate(podOwner, totalShareDeltaWei); emit CheckpointFinalized(lastCheckpointTimestamp, totalShareDeltaWei); ``` #### Shares for Partial Withdrawals (and More) <!-- A key detail of the checkpoint proof system is that *any ETH in the pod that we are not able to attribute to (i) existing shares or (ii) a current beacon chain balance MUST be awarded shares*. This means shares will be awarded for partial withdrawals, as well as any other ETH for which we can't attribute a source. --> A key detail of the checkpoint proof system is that *any ETH in the pod that we are not able to attribute to existing shares MUST be awarded shares*. This is because native ETH in the pod is fundamentally unattributable - we don't know where that ETH came from, because there are several sources of ETH that bypass contract execution: * *Beacon chain full withdrawals* * *Beacon chain partial withdrawals* * *Selfdestruct* * *Priority fee transfers to a block producer's fee recipient* Out of each of these sources, there is one in particular we need to consider: for validators with verified withdrawal credentials, *any ETH in the pod from beacon chain full withdrawals has already been awarded shares*! We need to be sure not to blindly grant shares for any ETH in the pod, as this may mean we're double-counting shares from exited validators. In order to avoid double-counting, finalizing a checkpoint proof results in a share delta that follows this equation, where $v(i)$ is an individual validator in a pod with $n$ validators with verified withdrawal credentials: $$ \Delta \text{podShares} = \Delta \text{podBalance} + \sum_{i=0}^{n} \Delta \text{beaconBalance}[ v(i) ] $$ When a validator exits, its $\Delta beaconBalance$ will be a negative number: its current balance (0) minus its previously-proven $beaconBalance$. This number is added to $podBalance$, ensuring those shares are not double-counted. This also means that *shares will be awarded for partial withdrawals* (as well as any other ETH for which we can't attribute a source). #### What Gets Snapshotted? The snapshot taken by `startCheckpoint` allows us to disambiguate "where" ETH is, while the sum of balance proofs across the pod's active validator set allow us to disambiguate "how much." This creates a guarantee we can use to award shares: ![image](https://hackmd.io/_uploads/ry87VsIZC.png) The key to this guarantee is the snapshot process used by `startCheckpoint`, which does two things: * Calls the [EIP-4788 beacon block root oracle](https://eips.ethereum.org/EIPS/eip-4788#specification) directly with the current `block.timestamp` as input. *This returns the parent beacon block root* - i.e. the beacon block root corresponding to `block.number - 1`. * Reads the pod's: * `activeValidatorCount` * Current ETH balance (minus anything already backed by shares) This checkpoint is used as the starting point for all subsequent proofs. ```solidity uint256 podBalanceGwei = (address(this).balance / GWEI_TO_WEI) - withdrawableRestakedExecutionLayerGwei; // Create checkpoint using the previous block's root for proofs, and the current // `activeValidatorCount` as the number of checkpoint proofs needed to finalize // the checkpoint. Checkpoint memory checkpoint = Checkpoint({ beaconBlockRoot: _getParentBlockRoot(uint64(block.timestamp)), podBalanceGwei: podBalanceGwei, balanceDeltasGwei: 0, proofsRemaining: activeValidatorCount }); ``` #### Why Does this Snapshot Work? A weird quirk of execution layer/beacon chain block processing is that in the execution layer, beacon chain withdrawals are processed AFTER all block transactions (this actually led to our sole "High" bug in Cantina). This quirk means that if we were to snapshot the *current* beacon block root alongside the *current* pod ETH balance, it may be possible for: * Our balance proof to show "0", because a withdrawal was just processed * The ETH to not be in the pod when the checkpoint is created, because the ETH won't hit the pod until the end of the block. This is why, when `startCheckpoint` is called, it queries the EIP-4788 oracle with the current `block.timestamp`, which actually returns the *previous block's root*! This means that when `startCheckpoint` is called: * The previous block's beacon chain withdrawals are *already in the pod* * Proofs against the previous block's root *will show 0 balance* if the validator has a withdrawal in that block (or if they have fully withdrawn in prior blocks) * *No beacon chain ETH has entered the pod since the last block* These properties mean that the $beaconBalances$ proven in a checkpoint are 100% distinct from the $podBalance$ snapshotted at the checkpoint's start, and we can award shares without worrying about double-counting. #### Checkpointing Incentives and Beacon Chain Slashings With upcoming releases of EigenLayer-native payments and slashing, EigenLayer and AVSs will depend on the EigenPod share values reported by the core protocol in order to: * Pay stakers/operators according to the amount of shares they have * Slash misbehaving stakers/operators up to the amount of shares they have As such, an important consideration for the pod accounting system is: **how do we ensure pod shares remain up-to-date?** After all, the pod accounting system's accuracy depends in large part on being supplied with regular balance update proofs, as this is the only "view" it has of beacon chain state. Note that a validator's beacon chain balance can drop due to a few events: * The validator is slashed * The validator is offline * The validator is offline, *and > 1/3 of ALL beacon chain validators are also offline* M2's answer to this question is `EigenPod.verifyBalanceUpdate`, which can be called by anyone (not just the pod owner) to prove that a validator's beacon chain balance has risen or dropped since the last seen proof. However, if a validator's balance drops, *there is never an incentive for the pod owner to prove this,* as it would decrease their shares. Checkpointing is slightly different: because shares are awarded for partial withdrawals, a pod owner is incenvitized to prove balance updates when: * They want to fully exit a validator * Their pod has accumulated enough partial withdrawals to offset the drop in balance In most cases, the checkpointing system provides adequate incentive to keep pod shares up to date. ##### Stale Balance Proofs Typically, `startCheckpoint` can only be called by the pod owner. This is because checkpoints MUST be finished before starting a new one - and as a potentially gas-intensive process, we want to enable the pod owner to decide when checkpointing is worth it. However, if *a large proportion of validators in a pod are slashed on the beacon chain,* it's unlikely the pod owner will want to perform a checkpoint. In this case, we rely on a "staleness proof" to allow a third party to start a checkpoint on behalf of the pod owner. Anyone can submit a staleness proof to `EigenPod.verifyStaleBalance`: ```solidity function verifyStaleBalance( uint64 beaconTimestamp, BeaconChainProofs.StateRootProof calldata stateRootProof, BeaconChainProofs.ValidatorProof calldata proof ) external; ``` `verifyStaleBalance` allows anyone to submit a proof that an ACTIVE validator in the pod was slashed on the beacon chain. If successful, this allows the caller to start a checkpoint. Note that if there is an existing checkpoint, the call will fail - the existing checkpoint needs to be finished before this call succeeds! --- ### Changes from M2 :::info Follow along with ongoing contract work here: [`eigenlayer-contracts/#515`](https://github.com/Layr-Labs/eigenlayer-contracts/pull/515). A brief summary of changes follows. **Note: there is currently no concrete ETA for this release!** These notes are an early look into the contract changes that will accompany the release -- we're hoping to get feedback on this as early as possible to know if these changes pose major problems for anyone! ::: #### Changed: Proofs Interface *Removed*: * Balance Update Proofs * `EigenPod.verifyBalanceUpdates()` * Withdrawal Processing * `EigenPod.verifyAndProcessWithdrawals()` * `EigenPod.provenWithdrawal()` * `EigenPod.sumOfPartialWithdrawalsClaimedGwei()` *Added*: ```solidity interface IEigenPod { /// State-changing methods function startCheckpoint(bool revertIfNoBalance) external; function verifyCheckpointProofs( BeaconChainProofs.BalanceContainerProof calldata balanceContainerProof, BeaconChainProofs.BalanceProof[] calldata proofs ) external; /// Events /// @notice Emitted when a checkpoint is created event CheckpointCreated(uint64 indexed checkpointTimestamp, bytes32 indexed beaconBlockRoot); /// @notice Emitted when a checkpoint is finalized event CheckpointFinalized(uint64 indexed checkpointTimestamp, int256 totalShareDeltaWei); /// @notice Emitted when a validator is proven for a given checkpoint event ValidatorCheckpointed(uint64 indexed checkpointTimestamp, uint40 indexed validatorIndex); /// @notice Emitted when a validaor is proven to have 0 balance at a given checkpoint event ValidatorWithdrawn(uint64 indexed checkpointTimestamp, uint40 indexed validatorIndex); /// Structs struct Checkpoint { bytes32 beaconBlockRoot; uint24 proofsRemaining; uint64 podBalanceGwei; int128 balanceDeltasGwei; } /// View methods function activeValidatorCount() external view returns (uint256); // note - this variable already exists in M2; this change just makes it public! function lastCheckpointTimestamp() external view returns (uint64); function currentCheckpointTimestamp() external view returns (uint64); function currentCheckpoint() external view returns (Checkpoint memory); } ``` *Context*: Much of the context and explanation for how these methods work can be found by reading through the upper section of this document. To summarize, this release removes two major `EigenPod` methods: * `verifyBalanceUpdates` * `verifyAndProcessWithdrawals` In their place, pod owners will use: * `startCheckpoint` * `verifyCheckpointProofs` These two new methods should provide all of the same functionality as the removed methods, with these additional benefits: * *Checkpoint proofs compound execution layer rewards*; i.e. partial withdrawals will be credited with delegatable shares when a checkpoint is finalized * *Checkpoint proofs batch beacon chain withdrawals (both partial and full)*. Rather than submitting one proof per withdrawal, a checkpoint proof requires *one proof per validator* to claim ALL withdrawals in the pod. * Gas estimates suggest this will cost ~50-60k gas per validator. * *No more reliance on third-party beacon chain oracle timestamps* and no need to wait for a third party to add timestamps to the beacon chain oracle * *No more `DelayedWithdrawalRouter`.* Instead, finalized checkpoints award any partial withdrawals with shares that can be withdrawn via the `DelegationManager` withdrawal queue, the same way all other EigenLayer shares are withdrawn. *Example User Flow - Claiming Partial Withdrawals* 1. Wait for some partial withdrawals to accumulate in your pod. 2. Call `EigenPod.startCheckpoint()`. This will update: * `EigenPod.currentCheckpointTimestamp` * `EigenPod.currentCheckpoint` 3. Query `EigenPod.currentCheckpoint()` and fetch the `beaconBlockRoot` that will be used to generate proofs for the new checkpoint. 4. Generate one balance proof for each validator in the `ACTIVE` state in your pod. * You can check if a validator is in the `ACTIVE` state by querying `EigenPod.validatorStatus(pubkeyHash).` `ACTIVE` validators have been verified via `verifyWithdrawalCredentials`, but have not been proven `WITHDRAWN`. * Note that this balance proof does NOT use [`Validator.effective_balance`](https://eth2book.info/capella/part3/containers/dependencies/#validator), but rather uses the current balance found in [`BeaconState.balances`](https://eth2book.info/capella/part3/containers/state/#beaconstate) 5. Submit these proofs to `EigenPod.verifyCheckpointProofs`. You can submit these proofs individually, or all at once. * Note that if you call `verifyCheckpointProofs` multiple times, the `stateRootProof` parameter can be left empty on subsequent calls; this value is cached the first time it is proven for a given checkpoint. * For each balance proof submitted: * `currentCheckpoint.proofsRemaining` decreases by 1 * `currentCheckpoint.balanceDeltasGwei` updates to include the balance delta calculated for that proof * When `currentCheckpoint.proofsRemaining` hits 0, the checkpoint is automatically finalized and your partial withdrawals are awarded shares. *Some important notes*: * *Once a checkpoint is started, it cannot be cancelled.* The pod owner must complete an existing checkpoint before starting a new one. * This is mainly due to the difficulty in updating/maintaining state mid-checkpoint. It ended up adding a ton of complexity trying to make checkpoints cancellable, so we went with this design. * *Finalizing a checkpoint does not grant shares to any partial withdrawals that entered the pod AFTER the checkpoint was started.* * This is due to [how the checkpoint snapshot process works](#What-Gets-Snapshotted). To get shares for these funds, you'd need another checkpoint. * *Providing a checkpoint proof that shows a validator with 0 balance will mark that validator as `WITHDRAWN` and decrement `EigenPod.activeValidatorCount`.* This means that for future checkpoint proofs, you won't need to provide a proof for this validator. --- #### Added: Staleness Proofs *Added*: ```solidity interface IEigenPod { function verifyStaleBalance( uint64 beaconTimestamp, BeaconChainProofs.StateRootProof calldata stateRootProof, BeaconChainProofs.ValidatorProof calldata proof ) external; } ``` For the most part, pod owners won't need to think about this method - it's here to allow a third party to start checkpoints if a validator has been slashed on the beacon chain. See [Checkpointing Incentives and Beacon Chain Slashings](#Checkpointing-Incentives-and-Beacon-Chain-Slashings) above for further explanation. #### Changed: Verifying Withdrawal Credentials *Removed:* * `EigenPodManager.beaconChainOracle()` * `EigenPodManager.updateBeaconChainOracle(...)` * `EigenPodManager.getBlockRootAtTimestamp(...)` *Changed:* * `EigenPod.verifyWithdrawalCredentials(...)` * Removed requirement to use an `oracleTimestamp` within `VERIFY_BALANCE_UPDATE_WINDOW_SECONDS (4.5 hours)` of `block.timestamp` * Removed requirement to use a timestamp added to beacon root oracle by a third party * Added ability to perform proofs against any valid beacon chain timestamp within 24 hours, without waiting for an oracle update * Changed timestamp semantics: `oracleTimestamp` parameter now corresponds to the *next valid block* after the beacon block root being used for proofs. * Added requirement that this proof timestamp MUST be greater than `currentCheckpointTimestamp` * Added requirement that the validator being proven MUST NOT already have an exit epoch set *Context*: `verifyWithdrawalCredentials` is seeing some minor changes to ensure it's compatible with the new checkpoint system. The interface will remain the same, but we've adjusted some of the conditions under which a withdrawal credential proof can be processed. In M2, `verifyWithdrawalCredentials` proofs: * Needed to use a specific `oracleTimestamp` added to a third-party-controlled `beaconChainOracle` * Needed to use an `oracleTimestamp` that was no older than 4.5 hours old These are both holdovers from our pre-Deneb system, which relied on `EigenPods` being supplied beacon chain block roots by a trusted party. However, we're in a post-Deneb world now, and [EIP-4788](https://eips.ethereum.org/EIPS/eip-4788) has us covered! We're removing the two requirements listed above. Instead, `verifyWithdrawalCredentials` proofs: * Can use ANY valid `block.timestamp` from the last 24 hours, as long as the timestamp is newer than the `currentCheckpointTimestamp` * Note that this `block.timestamp` should correspond to the timestamp of the block that comes *after* the beacon block you're proving against! This is due to [how EIP-4788 works](https://eips.ethereum.org/EIPS/eip-4788#block-processing). * Will revert if the [`Validator` container](https://eth2book.info/capella/part3/containers/dependencies/#validator) being proven shows that the validator has an `exit_epoch != FAR_FUTURE_EPOCH` * This is to prevent already-exited/exiting validators from proving withdrawal credentials, as it introduces an edge case issue in checkpoint proofs. --- #### Deprecated: Pre-M2 Pod Activation and `DelayedWithdrawalRouter` *Removed*: * `EigenPod.activateRestaking` * `EigenPod.withdrawBeforeRestaking` * `EigenPod.withdrawNonBeaconChainETHBalanceWei` * `EigenPod.hasRestaked()` * `EigenPod.nonBeaconChainETHBalanceWei` * `EigenPod.mostRecentWithdrawalTimestamp` *Changed*: * The `EigenPod` no longer calls the `DelayedWithdrawalRouter` * Existing withdrawals queued in the `DelayedWithdrawalRouter` will not be affected and will be claimable at their expected dates * All future withdrawals will go through the `DelegationManager` withdrawal queue. *Context - Pod Activation*: `activateRestaking` was originally designed to allow pre-M2 pod owners to "opt in" to the M2 upgrade by choosing to call `activateRestaking` and enable proofs on their EigenPod. Likewise, `withdrawBeforeRestaking` was design to allow pod owners that did not "opt in" to continue to withdraw consensus rewards without needing to participate in the M2 proof system. With checkpoint proofs, this separation between M1 and M2 pods is no longer important, as the checkpoint process directly supports both featuresets: * M1 pod owners can continue not restaking their validators' beacon chain ETH and accessing consensus rewards directly by calling `startCheckpoint`. * With no validators restaked via `verifyWithdrawalCredentials`, `startCheckpoint` will auto-complete a checkpoint that awards the pod owner with shares equal to the pod's native ETH balance. These can be restaked or withdrawn via the `DelegationManager`. * M2 pod owners that have restaked beacon chain validators can interact with the checkpoint proof system as normal. *Context - Removing `DelayedWithdrawalRouter`*: Because both featuresets now involve a pod owner claiming shares and restaking/withdrawing those shares via the `DelegationManager`, we saw an opportunity to further simplify the EigenPod system by removing the `DelayedWithdrawalRouter` entirely. This is highly desirable, as the `DelegationManager` queue is how the rest of EigenLayer's withdrawals are processed, and keeping the `DelayedWithdrawalRouter` around means we have 2 separate paths for funds to leave the system. Since we're removing the `DelayedWithdrawalRouter`, its use via `withdrawNonBeaconChainETHBalanceWei` is also being removed. --- #### Deprecated: Misc * Removed `EigenPod.MAX_RESTAKED_BALANCE_GWEI_PER_VALIDATOR` * This is a holdover from pre-M2 code that limited validator balances to 32 ETH. It's not really used in M2 code, and won't be used in checkpoint code. As of the [Pectra hard fork](https://eips.ethereum.org/EIPS/eip-7600), validators will likely be able to have balances far exceeding 32 ETH. --- ### Additional Notes #### Compatibility with Pectra The [Pectra hard fork](https://eips.ethereum.org/EIPS/eip-7600) brings several major changes, the most important of which are laid out below. **1)** *MaxEB increase to 2048 ETH* means validators can have much higher balances, and may have custom partial withdrawal sweep ceilings. The M2 proof system would struggle to support this because it tries to distinguish between partial/full withdrawals. However, checkpoint proofs don't care which is which! **2)** *Validator consolidation* allows two validators (a `source` and `target`) with the same withdrawal credentials to be consolidated. This sets the `source` validator's balance to 0, and the `target` validator's balance to `target + source`. It's still TBD whether consolidation is initiated on the consensus layer, or the execution layer (details below). In the latter case, the `EigenPod` itself would expose methods to allow a pod owner to initiate consolidation. Generally, checkpoint proofs handle consolidation without issue. Because the `source` will have a balance of 0, a checkpoint proof will move it to the `WITHDRAWN` state, and no further proofs will be required of it. Likewise, the `target` validator's balance delta will simply include the `source` validator's previous balance, so the overall share delta shouldn't change. However, recounting that checkpoint proofs are only required for validators in the pod's `ACTIVE` validator set (verified withdrawal credentials + not `WITHDRAWN`), there are a few important edge cases to consider: * `source: INACTIVE, target: ACTIVE`: if the `source` does not have verified withdrawal credentials, the change in `target's` balance will be picked up by the next checkpoint. * For consensus-layer consolidation, this could allow an edge case where `source` consolidates into `target` then calls `startCheckpoint` *in the same epoch*. If `source` then verifies withdrawal credentials using a timestamp *after `startCheckpoint`, but still in the same epoch,* the `effective_balance` used in the withdrawal credential proof will not reflect consolidation. * It's unclear from the existing EIP specs whether consolidation will change the `Validator` container state in any way. If, for example, it sets that validator's `exit_epoch`, then verifying withdrawal credentials in this case won't be possible and this is not a problem (`verifyWithdrawalCredentials` reverts if the validator has an `exit_epoch`) * If no changes are made to the `Validator` container, we'll need to change `verifyWithdrawalCredentials` to use `current_balance`, like the checkpoint system uses. * For execution-layer consolidation, this is no problem - we can simply reject consolidation for validators without verified withdrawal credentials. * `source: ACTIVE, target: INACTIVE`: if the `target` does not have verified withdrawal credentials, the change in `target's` balance will NOT be picked up by the next checkpoint. * For consensus-layer consolidation, this is an annoying problem to deal with. Checkpointing after this consolidation would show `source` balance drop to 0, and no corresponding balance increase in the pod's ETH balance. It would seem like the ETH vanishes - and, likewise, the checkpoint system will deduct shares accordingly. This doesn't put the user's assets at risk, but for the future EigenLayer-slashing feature, we don't want stakers to be able to "temporarily remove" their slashable assets! * The solution here would be to both open `verifyWithdrawalCredentials` to be callable by anyone (rather than just the pod owner), and to run watchers to monitor pods for this type of activity. It's not ideal, but there isn't really another option. * For execution-layer consolidation, this is no problem - we can simply reject consolidation for validators without verified withdrawal credentials. **The main question right now is whether consolidation is initiated at the consensus layer, or in the execution layer.** In the former case, we'll need to do a few things: * Open `verifyWithdrawalCredentials` to be callable by anyone, rather than just the pod owner * Run watchers to monitor for `source -> target` consolidation where `target` is `INACTIVE`. * Depending on how `Validator` containers change due to consolidation, we may need to change `verifyWithdrawalCredentials` to use `current_balance` proofs in addition to the `Validator` container proofs they currently use. *If consolidation is initiated in the execution layer, we don't need to make any immediate changes!* We can (at a later date) choose to add consolidation support to the `EigenPod` interface, and include the rules mentioned above to avoid their associated edge cases. #### Not Yet Mentioned (probably incomplete) collection of things this doc hasn't explicitly mentioned - want to get yall a first look for the call today; we can talk about this stuff if you want! * Checkpoint proofs reintroduce current balance proofs in favor of `effective_balance` proofs, as the latter results in ambiguity. * However, where our previous current balance proofs were expensive because we were proving something in both the `validators` and `current_balances` trees, the new balance proofs will only require the latter. We don't need to examine the `Validator` container after withdrawal credentials have been proven. * We're not using withdrawal proofs at all anymore - only `verifyWithdrawalCredentials` and balance proof snapshots. * We move a validator into the `WITHDRAWN` state if their snapshot balance proof shows a "current balance" of 0. * What happens when validators re-deposit after they've been marked withdrawn? What about validators that exit to the pod but they never verified withdrawal credentials? * When a checkpoint is finalized, if either of these have happened, that ETH will appear in the pod as "unaccounted for" (i.e. in the `podBalance` part of the checkpoint). We award it shares like anything else. * Incentives to update balances (and balancing those incentives against payments/slashing). Up for discussion!