changed a year ago
Linked with GitHub

EigenPod Redesign - Checkpoint Proofs

References

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.

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. They also compare the epoch the Withdrawal was created in with the withdrawable_epoch found in the Validator container. 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
- 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
- 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
- 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?

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:
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
});
  1. 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.
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:

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 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

The key to this guarantee is the snapshot process used by startCheckpoint, which does two things:

  • Calls the EIP-4788 beacon block root oracle 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.

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:

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

Follow along with ongoing contract work here: eigenlayer-contracts/#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:

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, but rather uses the current balance found in BeaconState.balances
  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.
  • 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:

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 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 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.
  • Will revert if the Validator container 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, validators will likely be able to have balances far exceeding 32 ETH.

Additional Notes

Compatibility with Pectra

The Pectra hard fork 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!
Select a repo