eigenlayer-contracts/#515
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.
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:
As long as we can determine these things without ambiguity, we can safely award shares that we know to be backed.
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:
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:
M2 uses this information to award or remove shares accordingly.
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:
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.
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
:
Withdrawals
are created every block, and as of the Dencun upgrade, there are a maximum of 16 withdrawals processed per block.
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."
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!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.
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 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 1verifyAndProcessWithdrawals
sets validatorInfo.status
to WITHDRAWN
and decreases activeValidatorCount
by 1A 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.
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:
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
});
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);
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:
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).
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:
The key to this guarantee is the snapshot process used by startCheckpoint
, which does two things:
block.timestamp
as input. This returns the parent beacon block root - i.e. the beacon block root corresponding to block.number - 1
.activeValidatorCount
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
});
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:
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:
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.
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:
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:
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:
In most cases, the checkpointing system provides adequate incentive to keep pod shares up to date.
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!
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!
Removed:
EigenPod.verifyBalanceUpdates()
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:
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
EigenPod.startCheckpoint()
. This will update:
EigenPod.currentCheckpointTimestamp
EigenPod.currentCheckpoint
EigenPod.currentCheckpoint()
and fetch the beaconBlockRoot
that will be used to generate proofs for the new checkpoint.ACTIVE
state in your pod.
ACTIVE
state by querying EigenPod.validatorStatus(pubkeyHash).
ACTIVE
validators have been verified via verifyWithdrawalCredentials
, but have not been proven WITHDRAWN
.Validator.effective_balance
, but rather uses the current balance found in BeaconState.balances
EigenPod.verifyCheckpointProofs
. You can submit these proofs individually, or all at once.
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.currentCheckpoint.proofsRemaining
decreases by 1currentCheckpoint.balanceDeltasGwei
updates to include the balance delta calculated for that proofcurrentCheckpoint.proofsRemaining
hits 0, the checkpoint is automatically finalized and your partial withdrawals are awarded shares.Some important notes:
WITHDRAWN
and decrement EigenPod.activeValidatorCount
. This means that for future checkpoint proofs, you won't need to provide a proof for this validator.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.
Removed:
EigenPodManager.beaconChainOracle()
EigenPodManager.updateBeaconChainOracle(...)
EigenPodManager.getBlockRootAtTimestamp(...)
Changed:
EigenPod.verifyWithdrawalCredentials(...)
oracleTimestamp
within VERIFY_BALANCE_UPDATE_WINDOW_SECONDS (4.5 hours)
of block.timestamp
oracleTimestamp
parameter now corresponds to the next valid block after the beacon block root being used for proofs.currentCheckpointTimestamp
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:
oracleTimestamp
added to a third-party-controlled beaconChainOracle
oracleTimestamp
that was no older than 4.5 hours oldThese 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:
block.timestamp
from the last 24 hours, as long as the timestamp is newer than the currentCheckpointTimestamp
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.Validator
container being proven shows that the validator has an exit_epoch != FAR_FUTURE_EPOCH
DelayedWithdrawalRouter
Removed:
EigenPod.activateRestaking
EigenPod.withdrawBeforeRestaking
EigenPod.withdrawNonBeaconChainETHBalanceWei
EigenPod.hasRestaked()
EigenPod.nonBeaconChainETHBalanceWei
EigenPod.mostRecentWithdrawalTimestamp
Changed:
EigenPod
no longer calls the DelayedWithdrawalRouter
DelayedWithdrawalRouter
will not be affected and will be claimable at their expected datesDelegationManager
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:
startCheckpoint
.
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
.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.
EigenPod.MAX_RESTAKED_BALANCE_GWEI_PER_VALIDATOR
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.
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.
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
)Validator
container, we'll need to change verifyWithdrawalCredentials
to use current_balance
, like the checkpoint system uses.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.
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!
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.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:
verifyWithdrawalCredentials
to be callable by anyone, rather than just the pod ownersource -> target
consolidation where target
is INACTIVE
.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.
(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!
effective_balance
proofs, as the latter results in ambiguity.
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.verifyWithdrawalCredentials
and balance proof snapshots.
WITHDRAWN
state if their snapshot balance proof shows a "current balance" of 0.podBalance
part of the checkpoint). We award it shares like anything else.