# <b>Lido Oracle</b> <br/>Associated slashings research
The purpose of this research is to find possible solutions to the algorithm for finalizing requests in the bunker mode, taking into account the associated slashings. We will consider a slashing associated with an request if it started before the request and ended after.
Everything described below applies only to bunker mode. In turbo mode, we do not limit request finalization by associated slashings.
Let's start with the specifications.
## Initial slashing
When a validator is slashed the following happens:
```python
def slash_validator(state: BeaconState,
slashed_index: ValidatorIndex,
whistleblower_index: ValidatorIndex=None) -> None:
"""
Slash the validator with index ``slashed_index``.
"""
epoch = get_current_epoch(state)
initiate_validator_exit(state, slashed_index)
validator = state.validators[slashed_index]
validator.slashed = True
validator.withdrawable_epoch = max(validator.withdrawable_epoch, Epoch(epoch + EPOCHS_PER_SLASHINGS_VECTOR))
state.slashings[epoch % EPOCHS_PER_SLASHINGS_VECTOR] += validator.effective_balance
decrease_balance(state, slashed_index, validator.effective_balance // MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR)
# Apply proposer and whistleblower rewards
proposer_index = get_beacon_proposer_index(state)
if whistleblower_index is None:
whistleblower_index = proposer_index
whistleblower_reward = Gwei(validator.effective_balance // WHISTLEBLOWER_REWARD_QUOTIENT)
proposer_reward = Gwei(whistleblower_reward * PROPOSER_WEIGHT // WEIGHT_DENOMINATOR)
increase_balance(state, proposer_index, proposer_reward)
increase_balance(state, whistleblower_index, Gwei(whistleblower_reward - proposer_reward))
```
```python
def initiate_validator_exit(state: BeaconState, index: ValidatorIndex) -> None:
"""
Initiate the exit of the validator with index ``index``.
"""
# Return if validator already initiated exit
validator = state.validators[index]
if validator.exit_epoch != FAR_FUTURE_EPOCH:
return
# Compute exit queue epoch
exit_epochs = [v.exit_epoch for v in state.validators if v.exit_epoch != FAR_FUTURE_EPOCH]
exit_queue_epoch = max(exit_epochs + [compute_activation_exit_epoch(get_current_epoch(state))])
exit_queue_churn = len([v for v in state.validators if v.exit_epoch == exit_queue_epoch])
if exit_queue_churn >= get_validator_churn_limit(state):
exit_queue_epoch += Epoch(1)
# Set validator exit epoch and withdrawable epoch
validator.exit_epoch = exit_queue_epoch
validator.withdrawable_epoch = Epoch(validator.exit_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY)
```
```python
def compute_activation_exit_epoch(epoch: Epoch) -> Epoch:
"""
Return the epoch during which validator activations and exits initiated in ``epoch`` take effect.
"""
return Epoch(epoch + 1 + MAX_SEED_LOOKAHEAD)
```
| Name | Value | Unit | Duration |
| ----------------------------------- | ------------- | ------------ | ------------ |
| EPOCHS_PER_SLASHINGS_VECTOR | uint64(2**13) | 8,192 epochs | ~36 days |
| MIN_VALIDATOR_WITHDRAWABILITY_DELAY | uint64(2**8) | 256 epochs | ~27 hours |
| MAX_SEED_LOOKAHEAD | uint64(2**2) | 4 epochs | 25.6 minutes |
- [slash_validator](https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/beacon-chain.md#modified-slash_validator)
- [initiate_validator_exit](https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#initiate_validator_exit)
What is interesting for us here:
- There is no easy way to tell by looking at a validator exactly when it was slashed.
- The larger the exit queue, the further `exit_epoch` and `withdrawable_epoch` are.
- The difference between `withdrawable_epoch` and `exit_epoch` can be pretty wide ranging from `MIN_VALIDATOR_WITHDRAWABILITY_DELAY` to `EPOCHS_PER_SLASHINGS_VECTOR - 1 - MAX_SEED_LOOKAHEAD` depending on the queue length.
## Correlation slashing
```python
def process_slashings(state: BeaconState) -> None:
epoch = get_current_epoch(state)
total_balance = get_total_active_balance(state)
adjusted_total_slashing_balance = min(sum(state.slashings) * PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR, total_balance)
for index, validator in enumerate(state.validators):
if validator.slashed and epoch + EPOCHS_PER_SLASHINGS_VECTOR // 2 == validator.withdrawable_epoch:
increment = EFFECTIVE_BALANCE_INCREMENT # Factored out from penalty numerator to avoid uint64 overflow
penalty_numerator = validator.effective_balance // increment * adjusted_total_slashing_balance
penalty = penalty_numerator // total_balance * increment
decrease_balance(state, ValidatorIndex(index), penalty)
```
- [process_slashings](https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/beacon-chain.md#slashings)
What is interesting for us here:
- Correlation slashing occurs at `withdrawable_epoch - EPOCHS_PER_SLASHINGS_VECTOR // 2` epoch. So it counts from the end, and the time to its occurrence depends on the queue at the moment of slashing.
## Request finalization
We want to finalize only requests which associated slashed validators have reached `withdrawable_epoch`. With both slash boundaries, it would be easy to determine which slashes should be accounted for and which should not, but `slashed_epoch` is not stored in the validator object.
Let's consider some scenarios and see how the numbers behave. For simplicity the current `churn_limit` is minimal and = 4.
### Small or empty queue
A number of validators are slashed at the same time in the 10,000 epoch. In this case the validator states will change as follows:
```json
[
{
exit_epoch: 10005, // epoch + 1 + MAX_SEED_LOOKAHEAD
withdrawable_epoch: 18192 // epoch + EPOCHS_PER_SLASHINGS_VECTOR
}, {
exit_epoch: 10005,
withdrawable_epoch: 18192
}, {
exit_epoch: 10005,
withdrawable_epoch: 18192
}, {
exit_epoch: 10005,
withdrawable_epoch: 18192
},
{
exit_epoch: 10006, // + 1, since churn_limit for the previous epoch is reached
withdrawable_epoch: 18192 // epoch + EPOCHS_PER_SLASHINGS_VECTOR
},
...
]
```
Until the `withdrawable_epoch` calculated in `slash_validator` reaches the `withdrawable_epoch` calculated in `initiate_validator_exit`, we can easily calculate the `slashed_epoch`.
```
// while the queue is small
slashed_epoch = withdrawable_epoch - EPOCHS_PER_SLASHINGS_VECTOR
```
### Large queue
A number of validators are slashed at the same time in the 10,000 epoch, the exit queue is 17,936 epochs.
```json
[
{
exit_epoch: 17936, // exit_queue_epoch
withdrawable_epoch: 18192 // exit_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY = epoch + EPOCHS_PER_SLASHINGS_VECTOR
}, {
exit_epoch: 17936,
withdrawable_epoch: 18192
}, {
exit_epoch: 17936,
withdrawable_epoch: 18192
}, {
exit_epoch: 17936,
withdrawable_epoch: 18192
},
{
exit_epoch: 17937, // + 1, since churn_limit for the previous epoch is reached
withdrawable_epoch: 18193 // exit_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY
},
...
]
```
If the queue exceeds `MIN_VALIDATOR_WITHDRAWABILITY_DELAY - EPOCHS_PER_SLASHINGS_VECTOR`, the `withdrawable_epoch` depends on the queue length.
In the example above, if the queue size exceeds `MIN_VALIDATOR_WITHDRAWABILITY_DELAY - EPOCHS_PER_SLASHINGS_VECTOR`, we cannot distinguish between a validator that slashed at epoch 10,000 and 10,001.
### Comparison of slashings
Validator slashing life cycle depending on the exit queue size:
```mermaid
gantt
axisFormat
todayMarker off
section Slasing<br>in a small<br>queue
active_ongoing :active, a_o1, 2023-01-01, 5d
active_slashed :crit, a_s1, after a_o1, 1d
exited_slashed :done, e_s1, after a_s1, 36d
withdrawal_possible :done, w_p1, after e_s1, 1d
withdrawal_done :done, w_d1, after w_p1, 1d
section Slashing<br>in a large<br>queue
active_ongoing :active, a_o2, 2023-01-01, 5d
active_slashed :crit, a_s2, after a_o2, 50d
exited_slashed :done, e_s2, after a_s2, 1d
withdrawal_possible :done, w_p2, after e_s2, 1d
withdrawal_done :done, w_d2, after w_p2, 1d
```
## Pseudo code
As a result, we can use a combined algorithm for finding the border of associated slashings: if possible, find slashed_epoch from the current state, if the queue is large, then make historical queries to the node.
```python
"""
Spec constants. Should be defined in env to support different chain configurations.
https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md
"""
FAR_FUTURE_EPOCH = 2**64 - 1
MIN_VALIDATOR_WITHDRAWABILITY_DELAY = 2**8
EPOCHS_PER_SLASHINGS_VECTOR = 2**13
SLOTS_PER_EPOCH = 2**5
def get_associated_slashings_border_epoch(ref_epoch):
"""
Returns the latest epoch to which the impact of the Lido validators slashing is accounted.
All completed slashings are ignored, in incompleted slashings it finds the earliest
slashed_epoch and returns the previous epoch. If there are no slashings or all are completed,
it returns ref_epoch.
"""
earliest_slashed_epoch_among_incomplete_slashings = get_earliest_slashed_epoch_among_incomplete_slashings(ref_epoch)
if earliest_slashed_epoch_among_incomplete_slashings is None:
"""
No incompleted slashings. Requests up to and including the reference epoch take into
account all impacts of associated slashings
x-----> | completed slashing
x------->| completed slashing
^ reference epoch
"""
return ref_epoch
else:
"""
There are incompleted slashings. Requests before earliest slashed epoch of incompleted
slashings take into account all impacts of associated slashings
x-----> | completed slashing
x---|---> incompleted slashing
x--|----> incompleted slashing
^ | earliest slashed epoch among incompleted slashings
^ reference epoch
"""
return earliest_slashed_epoch_among_incomplete_slashings - 1
def get_earliest_slashed_epoch_among_incomplete_slashings(ref_epoch):
"""
Returns the earliest slashed epoch among all incompleted slashings. Incompleted slashing
is considered if the validator is slashed and withdrawable_epoch > epoch
"""
assert ref_epoch >= 0, "Epoch should be >= 0"
lido_validators = fetch_all_lido_validators_states_by_epoch(ref_epoch)
lido_validators_slashed = filter_slashed_validators(lido_validators)
lido_validators_slashed_non_withdrawable = filter_non_withdrawable_validators(lido_validators_slashed, ref_epoch)
if len(lido_validators_slashed_non_withdrawable) == 0:
# All slashings are complete
return None
validators_with_earliest_exit_epoch = get_validators_with_earliest_exit_epoch(lido_validators_slashed_non_withdrawable)
"""
Get the earliest slashed_epoch for all incompleted slashings based on validators state
for reference slot. This works as long as the exit queue during the slashing is not comparable
to the EPOCHS_PER_SLASHINGS_VECTOR. For the same exit_epoch withdrawable_epoch must be the same,
so we can safely examine only one element of the array here.
https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#initiate_validator_exit
https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#slash_validator
"""
first_validator_with_earliest_exit_epoch = validators_with_earliest_exit_epoch[0]
earliest_slashed_epoch = calc_validator_slashed_epoch_from_state(first_validator_with_earliest_exit_epoch)
if earliest_slashed_epoch is not None:
return earliest_slashed_epoch
assert ref_epoch != 0, "Should not be undetectable slashing at epoch 0"
"""
The exit queue was too large at the moment of slashing and it's not possible to get slashed_epoch
from the state, so we go to search in the historical states
"""
earliest_slashed_epoch = find_earliest_slashed_epoch(validators_with_earliest_exit_epoch, ref_epoch)
return earliest_slashed_epoch
def get_validators_with_earliest_exit_epoch(validators):
"""
Finds the earliest exit_epoch in the list of validators and returns all validators that match it
"""
if len(validators) == 0:
return []
sorted_validators = sorted(validators, key = lambda validator: (validator["validator"]["exit_epoch"]))
earliest_exit_epoch = sorted_validators[0]["validator"]["exit_epoch"]
return filter_validators_by_exit_epoch(earliest_exit_epoch)
def calc_validator_slashed_epoch_from_state(validator):
"""
Calculates slashed epoch based on current validator state
Returns None in case it can't be calculated from the state
"""
slashed = validator["validator"]["slashed"]
exit_epoch = validator["validator"]["exit_epoch"]
withdrawable_epoch = validator["validator"]["withdrawable_epoch"]
assert slashed, "Validator is not slashed"
assert exit_epoch != FAR_FUTURE_EPOCH, "Exit epoch is not valid"
assert withdrawable_epoch != FAR_FUTURE_EPOCH, "Withdrawable epoch is not valid"
exited_period = withdrawable_epoch - exit_epoch
is_slashed_epoch_undetectable = exited_period > MIN_VALIDATOR_WITHDRAWABILITY_DELAY
if is_slashed_epoch_undetectable:
return None
return withdrawable_epoch - EPOCHS_PER_SLASHINGS_VECTOR
def find_earliest_slashed_epoch(validators, ref_epoch):
"""
Returns the earliest slashed epoch for the validator list, making historical queries
to the CL node and tracking slashed flag changes
"""
pubkeys = get_validators_pubkeys(validators)
withdrawable_epoch = min(get_validators_withdrawable_epochs(validators))
# Search boundaries
# it's possible to speed up the algorithm:
# - start with the slot of the last finalized request
# - iterate through checkpoints, so that the node doesn't waste time on state recovery
# - replace with activation epoch
start_search_epoch = 0
end_search_epoch = min(ref_epoch, withdrawable_epoch - EPOCHS_PER_SLASHINGS_VECTOR)
start_slot = get_epoch_first_slot(start_search_epoch)
end_slot = get_epoch_last_slot(end_search_epoch)
# Binary search
while start_slot + 1 < end_slot:
mid_slot = (end_slot + start_slot) // 2
validators = fetch_validators_state_by_slot(pubkeys, mid_slot)
slashed_validators = filter_slashed_validators(validators)
if len(slashed_validators) > 0:
# Ignore right half
end_slot = mid_slot - 1
else:
# Ignore left half
start_slot = mid_slot + 1
return get_epoch_for_slot(end_slot)
def get_epoch_first_slot(epoch):
return epoch * SLOTS_PER_EPOCH
def get_epoch_last_slot(epoch):
return (epoch + 1) * SLOTS_PER_EPOCH - 1
def fetch_all_lido_validators_states_by_epoch(epoch):
epoch_last_slot = get_epoch_last_slot(epoch)
lido_validators_pubkeys = get_lido_validators_keys(epoch_last_slot)
return fetch_validators_state_by_slot(lido_validators_pubkeys, epoch_last_slot)
def get_lido_validators_keys(slot):
# Returns lido validators keys
pass
def fetch_validators_state_by_slot(slot):
# Fetches validators state for the slot
# Jumps to the previous slot if a block in the slot is missed
# TODO: can we proof that this state in the initial chain?
pass
def filter_slashed_validators(validators):
return list(filter(lambda validator: (validator["validator"]["slashed"]), validators))
def filter_non_withdrawable_validators(validators, epoch):
return list(filter(lambda validator: (validator["validator"]["withdrawable_epoch"] > epoch), validators))
def filter_validators_by_exit_epoch(validators, exit_epoch):
return list(filter(lambda validator: (validator["validator"]["exit_epoch"] == exit_epoch), validators))
def get_validators_pubkeys(validators):
return list(map(lambda validator: (validator["validator"]["pubkey"]), validators))
def get_validators_withdrawable_epochs(validators):
return list(map(lambda validator: (validator["validator"]["withdrawable_epoch"]), validators))
```