This document is meant to explore and analyze the different mathematical operations we are performing in the slashing release. Primarily we want to ensure safety on rounding and overflow situations. Prior reading of the Shares Accounting is required to make sense of this document.
Within the context of a single Strategy, recall that updates to the deposit scaling factor
We can see here that calculating EigenPodManager
and StrategyManager
will not report share increases in this case. However, the other two terms may reach 0:
- When an operator is 100% slashed for a given strategy and their max magnitude
$m_n = 0$ - When a staker's
EigenPod
native ETH balance is 0 and their validators have all been slashed such that$l_n = 0$
In these cases, updates to a staker's deposit scaling factor will encounter a division by 0 error. In either case, we know that since either the operator was fully slashed or the staker was fully slashed for the beaconChainETHStrategy
then their withdrawable shares
In practice, if
- Any staker who is already delegated to this operator will be unable to deposit additional assets into the corresponding strategy
- Any staker that currently holds deposit shares in this strategy and is NOT delegated to the operator will be unable to delegate to the operator
Note that in the first case, it is possible for the staker to undelegate, queue, and complete withdrawals - though as
Additionally, if EigenPod
, and ALL of their validators have been ~100% slashed on the beacon chain - something that happens only when coordinated groups of validators are slashed. If this case occurs, an EigenPod
is essentially bricked - the pod owner should NOT send ETH to the pod, and should NOT point additional validators at the pod.
If an operator has their own Native ETH shares in EigenLayer and is fully slashed by an AVS (
These are all expected edge cases and their occurrences and side effects are within acceptable tolerances.
Let's examine potential overflow situations with respect to calculating a staker's withdrawable shares.
Below is the function in SlashingLib.sol
which calculates
Note: slashingFactor
=
function calcWithdrawable(
DepositScalingFactor memory dsf,
uint256 depositShares,
uint256 slashingFactor
) internal pure returns (uint256) {
/// forgefmt: disable-next-item
return depositShares
.mulWad(dsf.scalingFactor())
.mulWad(slashingFactor);
}
depositShares
are the staker’s shares mulWad
of the slashingFactor
operation should never result in a overflow, it will always result in a smaller or equal number.
The question now comes to depositShares.mulWad(dsf.scalingFactor())
and whether this term will overflow a uint256
. Let's examine the math behind this. The function SlashingLib.update
performs the following calculation:
Assuming:
$k_0 = 1$ - 0 <
$l_0$ ≤ 1 and is monotonically decreasing but doesn’t reach 0 - 0 <
$m_0$ ≤ 1 and is monotonically decreasing but doesn’t reach 0 - 0 ≤
$s_n, {s_{n+1}}$ ≤ 1e38 - 1 (MAX_TOTAL_SHARES = 1e38 - 1
in StrategyBase.sol) - 0 <
$d_n$ ≤ 1e38 - 1 ${s_{n+1}}={s_n} + {d_n}$
Rewriting above we can get the following by factoring out the k and cancelling out some terms.
The first term
The second term
Now in practice, the smallest values
So lets round up the first term
Because of the max shares in storage for a strategy is 1e38 - 1 and deposits must be non-zero we can actually come up with an upper bound on
After 1e38-1 iterations/deposits, the upper bound on k we calculate is 1e74 in the worst case scenario. This is technically possible if as a staker, you are delegated to an operator for the beaconChainStrategy where your operator has been slashed 99.9999999…% for native ETH but also as a staker you have had proportional EigenPod balance decreases up to 99.9999999…..%.
The max shares of 1e38-1 also accommodates the entire supply of ETH as well (only needs 27 bits). For normal StrategyManager strategies,
Clearly this value of 1e74 for
Bringing this all back to the calcWithdrawable
method used to calculate your actual withdrawable shares for a staker as well as the actual next
The staker depositScalingFactor is unbounded on how it can increase over time but because of the lower bounds we have
The SlashingLib.sol
introduces some small rounding precision errors due to the usage of mulWad
/divWad
operations in the contracts where we are doing a x * y / denominator
operation. In Solidity, we round down to the nearest integer introducing an absolute error of up to 1 wei. Taking this into consideration, in certain portions of code, we will explicitly use either take the floor or ceiling value of x * y / denominator
.
This has implications on several parts of the system. For example, completing a withdrawal as shares and having your updated withdrawable shares being less than what it was originally due to rounding. For stakers having a non-WAD beacon chain slashing factor(BCSF) this is essentially self induced from being penalized/slashed on the BC. For operator's have non-WAD maxMagnitudes for specific strategies, it is also a result of them being slashed by the OperatorSet(s) they are allocated to. Stakers should be wary of delegating to operators of low maxMagnitude for the strategies they they have deposits in. The impact of rounding error can result in a larger discrepancy between what they should have withdrawable vs what they actually can withdraw.
When an operator is slashed by an operatorSet in the AllocationManager
, we actually want to round up on slashing. Rather than calculating floor(x * y / denominator)
from mulDiv, we want ceiling(x * y / denominator)
. This is because we don’t want any kind of DOS scenario where an operatorSet attempting to slash an operator is rounded to 0; potentially possible if an operator registered for their own fake AVS and slashed themselves repeatedly to bring their maxMagnitude to a small enough value. This will ensure an operator is always slashed for some amount from their maxMagnitude which eventually, if they are slashed enough, can reach 0.
AllocationManager.slashOperator
// 3. Calculate the amount of magnitude being slashed, and subtract from
// the operator's currently-allocated magnitude, as well as the strategy's
// max and encumbered magnitudes
uint64 slashedMagnitude = uint64(uint256(allocation.currentMagnitude).mulWadRoundUp(params.wadsToSlash[i]));
There are some very particular edge cases where, due to rounding error, deposits can actually decrease withdrawble shares for a staker which is conceptually wrong.
The unit test DelegationUnit.t.sol:test_increaseDelegatedShares_depositRepeatedly
exemplifies this where there is an increasing difference over the course of multiple deposits between a staker's withdrawable shares and the staker's delegated operator shares.
Essentially, what’s happening in this test case is that after the very first deposit of a large amount of shares, subsequent deposits of amount 1000 are causing the getWithdrawable shares to actually decrease for the staker.
Since the operatorShares are simply incrementing by the exact depositShares, the operatorShares mapping is increasing as expected. This ends up creating a very big discrepancy/drift between the two values after performing 1000 deposits. The difference between the operatorShares and the staker’s withdrawableShares ends up being 4.418e13
.
Granted the initial deposit amount was 4.418e28
which is magnitudes larger than the discrepancy here but this its important to note the side effects of the redesigned accounting model.
Instead of purely incremented/decremented amounts, we have introduced magnitudes and scaling factor variables which now result in small amounts of rounding error from division in several places. We deem this rounding behavior to be tolerable given the costs associated for the number of transactions to emulate this and the proportional error is very small.
As can be observed in the SlashingLib.sol
library, we round up on the operatorShares when slashing and round down on the staker's withdrawableShares. If we look at a core invariant of the shares accounting model, we ideally want to preserve the following:
where
However due to rounding limitations, there will be some error introduced in calculating the amount of operator shares to slash above and also in calculating the staker's withdrawableShares. To prevent a situation where all stakers were to attempt to withdraw and the operatorShares underflows, we round up on the operatorShares when slashing and round down on the staker's withdrawableShares.
So in practice, the above invariant becomes.
Upwards rounding on calculating the amount of operatorShares to give to an operator after slashing is intentionally performed in SlashingLib.calcSlashedAmount
.
For calculating a staker's withdrawableShares, there are many different factors to consider such as calculating their depositScalingFactor, their slashingFactor, and calculating the amount of withdrawable shares altogether with their depositShares. These variables are all by default rounded down in calculation and is expected behavior for stakers.
Related to the above rounding error on deposits, we want to calculate what is the worst case rounding error for a staker depositing shares into EigenLayer. That is, what is the largest difference between the depositShares deposited and the resulting withdrawableShares? For a staker who initially deposits without getting slashed, these two values should conceptually be equal. Let's examine below.
Below is a code snippet of SlashingLib.sol
function update(
DepositScalingFactor storage dsf,
uint256 prevDepositShares,
uint256 addedShares,
uint256 slashingFactor
) internal {
// If this is the staker's first deposit, set the scaling factor to
// the inverse of slashingFactor
if (prevDepositShares == 0) {
dsf._scalingFactor = uint256(WAD).divWad(slashingFactor);
return;
}
...
function calcWithdrawable(
DepositScalingFactor memory dsf,
uint256 depositShares,
uint256 slashingFactor
) internal pure returns (uint256) {
/// forgefmt: disable-next-item
return depositShares
.mulWad(dsf.scalingFactor())
.mulWad(slashingFactor);
}
Mathematically, withdrawable shares can be represented as below
Substituting WAD.divWad(slashingFactor)
(see update function above) if the staker only has done one single deposit of amount maxMagnitude.mulWad(beaconChainScalingFactor)
Above is the real true value of the amount of withdrawable shares a staker has but in practice, there are rounding implications at each division operation. It becomes the following
Each floor operation can introduce a rounding error of at most 1 wei. Because there are nested divisions however, this error can result in a total error thats larger than just off by 1 wei.
We can rewrite parts of above with epsilon
- First inner rounded term
- Second rounded term
- Third rounded term
- Now bringing it all back to the original equation
After expansion and some simplification
Note that (higher-order terms) are the terms with multiple epsilon terms where the amounts become negligible, because each term
The true value term is the following:
But we can see this term show in the withdrawableShares(rounded) above in the first term! Then we can see that we can represent the equations as the following.
This intuitively makes sense as all the rounding error comes from the epsilon terms and how they propagate out from being nested. Therefore the introduced error from rounding are all the rounding terms added up ignoring the higher-order terms.
Now lets assume the worst case scenario of maximizing this sum above, if each epsilon
Assuming close to max values that results in rounding behaviour, we can maximize this total sum by having
Framed in another way, the amount of loss a staker can have is