# EPF Weeks 7 and 8 Update These weeks, during testing, I found a fundamental problem in how fees are handled in the current eODS specs. A `fee` is the amount of Gwei the Validator (operator) receives from a Delegator for operating the infrastructure. The fee is defined as `fee_quotient`, and currently is set when a Validator does an ACTIVATE_OPERATOR request. The `fee_quotient` was initially thought to be a quotient from the delegated amount. The math is simple, multiply two values and get the result. The idea was to apply this quotient when an Undelegation (or Redelegation) occurs, and only for the undelegated/redelegated amount: `validator_fee = undelegation_exit.amount * delegated_validator.fee_quotient` Looking better at this quotient lead me down a rabbit hole where I found conceptual issues with how rewards, penalties and slashing is applied in eODS. Let's zoom in on the rewards/penalties logic: **Rewards:** ```python for index in range(len(delegated_validator.delegators_quotas)): delegated_validator.delegated_balances[index] += amount * delegated_validator.delegators_quotas[index] ``` **Penalties:** ```python for index in range(len(delegated_validator.delegators_quotas)): delegated_validator.delegated_balances[index] -= amount * delegated_validator.delegators_quotas[index] ``` **DelegatedValidator:** ```python class DelegatedValidator(Container): validator: Validator delegated_validator_quota: uint64 delegators_quotas: List[Quota, DELEGATOR_REGISTRY_LIMIT] delegated_balances: List[Gwei, DELEGATOR_REGISTRY_LIMIT] total_delegated_balance: Gwei fee_quotient: uint64 ``` If we look at the logic above, we see that the rewards and penalties are applied to the DelegatedValidator's delegated_balances. This means we loose track of how much was actually rewarded/penalised in any given time period. We don't store any deltas or initial values, we bundle everything together. This raises questions like: how much do we need to slash a delegator? How much of that delegated balance is rewards? How can we correctly calculate the fees if we can't split the values (delegated, rewarded, slashed, penalised). If we add the ExitQueue items in the mix, which are also balances with different gains (but for different time periods) we get a really complex problem that should not even exist. There were a few ways to unbundle this into something manageable. 1. We store in the `DelegatedValidator` the raw delegated amounts (basically sum every delegated amount from the same delegator). This would allow us to calculate the gains/losses by doing something like: `gains_losses = delegated_balances[delegator_index] - raw_delegations[delegator_index]` 2. We store in the `DelegatedValidator` the deltas of rewards/penalties. So we sum up every reward received and every penalty, and we use those values when needed. 3. We go even deeper and store the fees themselves. Number 3 was chosen. To wrap my head around this issue I had to create a small prototype script that allowed me to quickly iterate and verify the math and new concepts: https://github.com/gorondan/consensus-specs/blob/patch_validator-fee-calculation/validator-fee.py Fee calculation is a function of time, and we wanted to avoid storing the time, for optimisation purposes. Assuming a delegator has an amount `N` delegated for a period `P` it will produce rewards `R` for that period. If `N` becomes `N-n` and `P` becomes `P+p` the rewards received will be less. So by only storing one value, the delegated amount, we don't know how much are the rewards for the period `0->P+p`. The way to solve this without storing every reward/penalty received with the epoch in which it was received is to precalculate the fees owed to the operator, on each reward/penalty tick. This is why `validators_fees` was introduced in the DelegatedValidator. The formulas now become: ```python def apply_delegations_rewards(self, amount: float) -> None: self.total_delegated_balance += amount * (1- self.delegated_validator_quota) # Rewards are compounded in total delegated balance based on delegators total quota for index in range(len(self.delegators_quotas)): self.delegated_balances[index] += amount * self.delegators_quotas[index] self.validators_fees[index] += amount * self.delegators_quotas[index] * self.fee_quotient # this was added for the patch def apply_delegations_penalties(self, amount: float) -> None: self.total_delegated_balance -= amount * (1- self.delegated_validator_quota) # Penalties are compounded in total delegated balance based on delegators total quota for index in range(len(self.delegators_quotas)): self.delegated_balances[index] -= amount * self.delegators_quotas[index] self.validators_fees[index] -= amount * self.delegators_quotas[index] * self.fee_quotient # this was added for the patch ``` The withdrawal function will become: ```python def withdraw_post(self, delegator_index: float, amount: float): withdraw_from_validator_fee_ratio = amount / self.delegated_balances[delegator_index] self.delegated_balances[delegator_index] -= amount self.total_delegated_balance -= amount withdraw_from_validator_fee = self.validators_fees[delegator_index] * withdraw_from_validator_fee_ratio self.validators_fees[delegator_index] -= withdraw_from_validator_fee validator_fee = withdraw_from_validator_fee self.validator_balance += validator_fee self.delegators_balances[delegator_index] += amount - validator_fee self.recalculate_delegator_quotas() ``` One other interesting decision reached is the fact that an operator should not receive any feed if it failed to operate in an honest way. This is why, if `validators_fees[delegator_index]` becomes less than 0, no fees will be owed. All the negative `validators_fees` balances will have to crawl back up to a positive value before the operator can profit from 'operating' on behalf of delegators. The specs will be updated to reflect this changes, there is a PR in works for that.