Rewards Algorithm
Detailed description of how node operator rewards are calculated for Constellation
There are two different times that rewards are issued to Node Operators in Constellation: when a minipool is processed and when a Merkle claim is submitted. The rewards from each of these are distributed differently, so we'll treat each as a separate algorithm, outlined below.
First we check the beacon node for all relevant validators and update their activationEpoch
and exitEpoch
, translating them into the starting blocks of those epochs, and store that data with the validators in the database as activationBlock
and exitBlock
.
In order to initiate each reward processing, we then scan for events between the last block we've scanned and the current block, looking for `MinipoolProcessed` and `MerkleClaimSubmitted` events. We then assign rewards based on algorithm according to the event type:
MerkleClaimSubmitted
NodeSet does not reimplement or duplicate the complicated logic for Rocket Pool's Merkle claim rewards system. Instead, all Merkle rewards are split among all node operators only according to their participation rate (i.e. their minipools' specific rewards algorithm is not taken into account). For example, since Saturn 0 changed rewards allocations after Constellation was launched, any minipools created after Saturn 0 will receive slightly less Merkle rewards (and vice-versa: users that created minipools using the prior system will receive slightly more Merkle rewards). This effect is averaged out, so if more Saturn 0 minipools are added to the system over time, this difference will be reduced.
When a MerkleClaimSubmitted
event is found, the first thing we do is find the block of the previous MerkleClaimSubmitted
event (using the block the contract was deployed if there is no previous MerkleClaimSubmitted
event). We'll call this value fundingStartBlock
. We then assign the block of the new MerkleClaimSubmitted
event to a value called fundingEndBlock
and the amount
in the new MerkleClaimSubmitted
event to fundingAmount
.
The idea here is that the rewards from the current MerkleClaimSubmitted
event represent the work done in the period of time since the last MerkleClaimSubmitted
event, and so the rewards of the current event should be distributed among all validators active between the previous event and the current one.
To calculate the amount that each validator should receive, we get a list of all validators that have an activationBlock
before the fundingEndBlock
and are either still active or have an exitBlock
later than the fundingStartBlock
, which in effect gets a list of all validators that were active for some period overlapping the time between the previous event and the current one.
Then for every validator we calculate how many "shares" of the reward they've earned:
shares = min(exitBlock, fundingEndBlock) - max(activationBlock, fundingStartBlock)
Which in effect counts the number of blocks the validator has been active that overlap with the period of time between the last MerkleClaimSubmitted
event and the current one.
We then total up all validators' shares
amounts into totalShares
, allowing us to calculate the percentage of the total pool of shares that each validator has operated, and we award each validator that percentage of the total fundingAmount
:
awards = fundingAmount * (shares / totalShares)
Example
Let's say we have 4 validators:
Validator A has a
startBlock
of390000
and anexitBlock
of411000
Validator B has a
startBlock
of395000
and anexitBlock
of416000
Validator C has a
startBlock
of400000
and is still activeValidator D has a
startBlock
of412000
and is still active
We have a new MerkleClaimSubmitted
event at block 413000
with an amount of 50000
and the previous MerkleClaimSubmitted
event at block 410000
(the amount of the previous event is irrelevant).
Each validator would have the following shares:
Validator A:
min(411000, 413000) - max(390000, 410000)
=411000 - 410000
=1000
Validator B:
min(416000, 413000) - max(395000, 410000)
=413000 - 410000
=3000
Validator C:
min(infinite, 413000) - max(400000, 410000)
=413000 - 410000
=3000
Validator D:
min(infinite, 413000) - max(412000, 410000)
=413000 - 412000
=1000
The total number of shares would be 1000 + 3000 + 3000 + 1000 = 8000
So each validator's percentage of the reward would be:
Validator A:
1000 / 8000 = 0.125
Validator B:
3000 / 8000 = 0.375
Validator C:
3000 / 8000 = 0.375
Validator D:
1000 / 8000 = 0.125
And with the event amount being 50000
, the final rewards would be:
Validator A:
50000 * 0.125 = 6250
Validator B:
50000 * 0.375 = 18750
Validator C:
50000 * 0.375 = 18750
Validator D:
50000 * 0.125 = 6250
MinipoolProcessed
Whenever a MinipoolProcessed
event is found, we first get the ethRewards
* from the event and, using the same logic as the contract, we get the noFee
from the minipoolData
of the validator, then run:
mulDiv(ethRewards, noFee, CALC_BASE)
(where CALC_BASE = 10 ** 18
)
Using an off-chain implementation of OpenZeppelin's mulDiv operation. We then assign that amount as rewards to the validator.
The reason we need to re-perform the same calculation as onEthBeaconRewardsReceived
is that onEthBeaconRewardsReceived
was added in an update, so the oldest processing events don't have a corresponding onEthBeaconRewardsReceived
event.
* Currently the MinipoolProcessed
event erroneously reports ethRewards
as 0
if the minipool was finalized, so when the finalized
value is true in the event we instead recalculate the correct ethRewards
value off-chain using logic identical to the contract.
Unprocessed Rewards
In the dashboard you may see "unprocessed rewards" for your validators. These are calculated by looking at the balance of the validator's minipool address and calculating the hypothetical reward amount using the same calculation used for calculating rewards from MinipoolProcessed
events. Essentially, it is the amount of rewards expected if you were to initiate a minipool processing event at the current moment (not including the transaction costs of doing so).
The balances of each validator are updated every 10,000 blocks, so the balance in the dashboard may not immediately reflect the latest balance, but the actual amount rewarded to you when a minipool is processed uses the values from the actual MinipoolProcessed
events, so the actual reward amounts will always be accurate and the unprocessed amounts will at worst reflect a slightly outdated, and thus lower, amount than reality.
Last updated