The rewards implementation is a weight-based pool. When the pool receives rewards, an operator's share of those rewards is equal to their share of the pool.
The pool provides 3 basic functions:
- updating an operator's weight in the pool
- granting the pool rewards
- withdrawing an operator's rewards
On top of those basic functions, higher level concepts can be constructed. For example, joining the pool is implemented as updating an operator's weight from 0 to positive. Leaving the pool is implemented as updating the operator's weight from positive to 0.
In order to accomplish these main functions, we create 3 pieces of state:
struct OperatorRewards {
uint96 accumulated;
uint96 available;
uint32 weight;
}
uint96 internal globalRewardAccumulator; // (1)
uint96 internal rewardRoundingDust; // (2)
mapping(uint32 => OperatorRewards) internal operatorRewards; // (3)
The (1) globalRewardAccumulator
represents how much reward a 1-weight
operator would have accumulated since genesis. Since we're working in integers,
and since rewards won't always be cleanly divisible by the pool weight, there
carry-over, which is stored in (2) rewardRoundingDust
.
Finally, the (3) operatorRewards
keep track of each operator's individual
state indexed by their id
. An operator's accumulated
value represents a
snapshot of the globalRewardAccumulator
at the time they were last updated.
Their available
value represents how much reward is available for withdraw,
as of their most recent update. Their weight
is their weight in the pool.
To see how all of these pieces of state interact, we can go through some event logs.
event 1) Alice (id 1) joins the pool with weight 10
event 2) 123 rewards are granted to the pool
event 3) Alice withdraws their rewards
We start at a fresh state:
globalRewardAccumulator: 0
rewardRoundingDust: 0
operatorRewards: {}
Joining the pool is handled with updateOperatorRewards(1, 10)
(some
complexity abridged to be introduced later)
function updateOperatorRewards(uint32 operator, uint32 newWeight) internal {
uint96 acc = globalRewardAccumulator;
OperatorRewards memory o = operatorRewards[operator];
uint96 accruedRewards = (acc - o.accumulated) * uint96(o.weight);
o.available += accruedRewards;
o.accumulated = acc;
o.weight = newWeight;
operatorRewards[operator] = o;
}
Following the math, we get:
acc = 0
o = {accumulated: 0, available: 0, weight: 0}
accruedRewards = (0 - 0) * 0 = 0
o.available = 0 + 0 = 0
o.accumulated = 0
o.weight = 10
operatorRewards: {1: {accumulated: 0, available: 0, weight: 10}}
// new state
globalRewardAccumulator: 0
rewardRoundingDust: 0
operatorRewards: {1: {accumulated: 0, available: 0, weight: 10}}
Ever time an operator is updated, they accrue rewards equal to however much the
globalRewardAccumulator
accrued in between now and the last time they were
updated, multiplied by their weight (since the acccumulator has a weight of 1).
We use that to inform how many rewards are available to them and then update
their snapshot of the accumulator state and their weight.
Next, 123 rewards are granted to the pool. We call addRewards(123, 10)
function addRewards(uint96 rewardAmount, uint32 currentPoolWeight) internal {
require(currentPoolWeight >= 0, "No recipients in pool");
uint96 totalAmount = rewardAmount + rewardRoundingDust;
uint96 perWeightReward = totalAmount / currentPoolWeight;
uint96 newRoundingDust = totalAmount % currentPoolWeight;
globalRewardAccumulator += perWeightReward;
rewardRoundingDust = newRoundingDust;
}
Following the math, we get:
totalAmount = 123 + 0 = 123
perWeightReward = 123 / 10 = 12
newRoundingDust = 123 % 10 = 3
globalRewardAccumulator = 0 + 12 = 12
rewardRoundingDust = 3
// new state
globalRewardAccumulator: 12
rewardRoundingDust: 3
operatorRewards: {1: {accumulated: 0, available: 0, weight: 10}}
Whenever rewards are distributed, we only update globalRewardAccumulator
and rewardRoundingDust
, never any of the operators. Those are only updated
lazily via updateOperatorRewards
. Note that at this point, alice's
accumulator is 12 rewards behind the global accumulator!
In order to Alice to withdraw her rewards, she shoud update herself first,
since her available
state is 0
. So, first we call updateOperatorRewards(1, 10)
acc = 12
o = {accumulated: 0, available: 0, weight: 10}
accruedRewards = (12 - 0) * 10 = 120
o.available = 0 + 120 = 120
o.accumulated = 12
o.weight = 10
operatorRewards: {1: {accumulated: 12, available: 120, weight: 10}}
// new state
globalRewardAccumulator: 12
rewardRoundingDust: 3
operatorRewards: {1: {accumulated: 12, available: 120, weight: 10}}
Some amount of reward (an amount between 0 and the total operator weight) will
be unavailable for withdraws due to how the rewardRoundingDust
works. In
threshold, the weight precision is 1 weight = 1 T, (1e18 divisor), but rewards
are represented with full precision (1e18), so numerically, even small rewards
should greatly exceed the total pool weight.
Alice is now ready to withdraw! We call withdrawOperatorRewards(1)
function withdrawOperatorRewards(uint32 operator)
internal
returns (uint96 withdrawable)
{
OperatorRewards storage o = operatorRewards[operator];
withdrawable = o.available;
o.available = 0;
}
// new state
globalRewardAccumulator: 12
rewardRoundingDust: 3
operatorRewards: {1: {accumulated: 12, available: 0, weight: 10}}
This returns 120
to the caller, and sets Alice's available rewards to 0. The
Rewards.sol
code isn't responsible for handling any token transaction, only
keeping track of the rewards state. That 120
amount is handled by the
SortitionPool
, to send Alice the appropriate amount of tokens.
State + Event Logs:
event 1) pool is created
globalRewardAccumulator: 0
rewardRoundingDust: 0
operatorRewards: {}
event 2) Alice (id 1) joins the pool with weight 10
globalRewardAccumulator: 0
rewardRoundingDust: 0
operatorRewards: {1: {accumulated: 0, available: 0, weight: 10}}
event 3) 123 rewards are granted to the pool
globalRewardAccumulator: 12
rewardRoundingDust: 3
operatorRewards: {1: {accumulated: 0, available: 0, weight: 10}}
event 4) Update Alice
globalRewardAccumulator: 12
rewardRoundingDust: 3
operatorRewards: {1: {accumulated: 12, available: 120, weight: 10}}
event 5) Withdraw Alice's Rewards
globalRewardAccumulator: 12
rewardRoundingDust: 3
operatorRewards: {1: {accumulated: 12, available: 0, weight: 10}}
return: 120
State + Event Logs:
event 1) pool is created
globalRewardAccumulator: 0
rewardRoundingDust: 0
operatorRewards: {}
event 2) Alice (id 1) joins the pool with weight 10
globalRewardAccumulator: 0
rewardRoundingDust: 0
operatorRewards: {1: {accumulated: 0, available: 0, weight: 10}}
event 3) 123 rewards are granted to the pool
globalRewardAccumulator: 12
rewardRoundingDust: 3
operatorRewards: {1: {accumulated: 0, available: 0, weight: 10}}
event 4) Bob (id 2) joins the pool with weight 20
globalRewardAccumulator: 12
rewardRoundingDust: 3
operatorRewards: {
1: {accumulated: 0, available: 0, weight: 10}
2: {accumulated: 12, available: 0, weight: 20}
}
event 5) 321 rewards are granted to the pool
globalRewardAccumulator: 22
rewardRoundingDust: 24
operatorRewards: {
1: {accumulated: 0, available: 0, weight: 10}
2: {accumulated: 12, available: 0, weight: 20}
}
event 6) Update Alice
globalRewardAccumulator: 22
rewardRoundingDust: 24
operatorRewards: {
1: {accumulated: 22, available: 220, weight: 10}
2: {accumulated: 12, available: 0, weight: 20}
}
event 7) Withdraw Alice's rewards
globalRewardAccumulator: 22
rewardRoundingDust: 24
operatorRewards: {
1: {accumulated: 22, available: 0, weight: 10}
2: {accumulated: 12, available: 0, weight: 20}
}
return: 220
event 8) Update Bob
globalRewardAccumulator: 22
rewardRoundingDust: 24
operatorRewards: {
1: {accumulated: 22, available: 0, weight: 10}
2: {accumulated: 22, available: 200, weight: 20}
}
event 9) Withdraw Bob's Rewards
globalRewardAccumulator: 22
rewardRoundingDust: 24
operatorRewards: {
1: {accumulated: 22, available: 0, weight: 10}
2: {accumulated: 22, available: 0, weight: 20}
}
return 200
In theory, Alice should be given 100% of the first 123
reward, and then 1/3
of the 321
reward coming out to a total of 230, which is 51.8% of the
rewards. Bob should only receive 2/3 of the 321 reward coming to a total of
214, which is 48.2%. Since the reward amounts are close to the weight amounts,
the dust is significant enough to be impactful here.
A total of 420 rewards were withdrawn by Alice and Bob (the other 24 are
marooned in rewardRoundingDust
). The higher the reward amount is relative to
the weight amount, the less this is significant.
In addition to the above functions, we want to be mark operators as "ineligable" for rewards, temporarily. In practice, if Alice and Bob both have 10 weight in the pool and 100 rewards are added while Bob is ineligable, Alice gets 50, Bob gets 0, and the pool owner is able to retrieve those 50 that Bob would have gotten at a later date.
In order to accomplish this, we need two more pieces of state:
uint96 public ineligibleEarnedRewards;
struct OperatorRewards {
uint32 ineligibleUntil;
}
We keep track of the total amount of reward that operators accumulated while
they were ineligable, and each operator keeps track of when they're allowed to
be eligible again. If ineligibleUntil == 0
, the operator is eligible.
That means that updateOperatorRewards
gets a modification:
function updateOperatorRewards(uint32 operator, uint32 newWeight) internal {
uint96 acc = globalRewardAccumulator;
OperatorRewards memory o = operatorRewards[operator];
uint96 accruedRewards = (acc - o.accumulated) * uint96(o.weight);
if (o.ineligibleUntil == 0) {
o.available += accruedRewards;
} else {
ineligibleEarnedRewards += accruedRewards;
}
o.accumulated = acc;
o.weight = newWeight;
operatorRewards[operator] = o;
}
We replaced o.available += accruedRewards;
with
if (o.ineligibleUntil == 0) {
o.available += accruedRewards;
} else {
ineligibleEarnedRewards += accruedRewards;
}
If the operator is eligible, they accrue rewards, otherwise,
ineligibleEarnedRewards
accrues those rewards instead.
Finally, the contract owner needs a way to extract ineligable rewards:
function withdrawIneligibleRewards() internal returns (uint96 withdrawable) {
withdrawable = ineligibleEarnedRewards;
ineligibleEarnedRewards = 0;
}
The rest is managing eligibility properly - methods that set and restore a operator's eligibility, and to make sure that doing so properly updates the operator's state.
State + Event Logs
event 1) pool is created
globalRewardAccumulator: 0
rewardRoundingDust: 0
ineligibleEarnedRewards: 0
operatorRewards: {}
event 2) Alice (id 1) joins the pool with weight 10
globalRewardAccumulator: 0
rewardRoundingDust: 0
ineligibleEarnedRewards: 0
operatorRewards: {1: {accumulated: 0, available: 0, weight: 10, ineligibleUntil: 0}}
event 3) 123 rewards are granted to the pool
globalRewardAccumulator: 12
rewardRoundingDust: 3
ineligibleEarnedRewards: 0
operatorRewards: {1: {accumulated: 0, available: 0, weight: 10, ineligibleUntil: 0}}
event 4) Bob (id 2) joins the pool with weight 20
globalRewardAccumulator: 12
rewardRoundingDust: 3
ineligibleEarnedRewards: 0
operatorRewards: {
1: {accumulated: 0, available: 0, weight: 10, ineligibleUntil: 0}
2: {accumulated: 12, available: 0, weight: 20, ineligibleUntil: 0}
}
event 5) Bob is set as ineligable until 100000 seconds from now
globalRewardAccumulator: 12
rewardRoundingDust: 3
ineligibleEarnedRewards: 0
operatorRewards: {
1: {accumulated: 0, available: 0, weight: 10, ineligibleUntil: 0}
2: {accumulated: 12, available: 0, weight: 20, ineligibleUntil: event5-time + 10000}
}
event 6) 321 rewards are granted to the pool
globalRewardAccumulator: 22
rewardRoundingDust: 24
ineligibleEarnedRewards: 0
operatorRewards: {
1: {accumulated: 0, available: 0, weight: 10, ineligibleUntil: 0}
2: {accumulated: 12, available: 0, weight: 20, ineligibleUntil: event5-time + 10000}
}
event 7) Update Alice
globalRewardAccumulator: 22
rewardRoundingDust: 24
ineligibleEarnedRewards: 0
operatorRewards: {
1: {accumulated: 22, available: 220, weight: 10, ineligibleUntil: 0}
2: {accumulated: 12, available: 0, weight: 20, ineligibleUntil: event5-time + 10000}
}
event 8) Withdraw Alice's rewards
globalRewardAccumulator: 22
rewardRoundingDust: 24
ineligibleEarnedRewards: 0
operatorRewards: {
1: {accumulated: 22, available: 0, weight: 10, ineligibleUntil: 0}
2: {accumulated: 12, available: 0, weight: 20, ineligibleUntil: event5-time + 10000}
}
return: 220
event 9) Update Bob
globalRewardAccumulator: 22
rewardRoundingDust: 24
ineligibleEarnedRewards: 200
operatorRewards: {
1: {accumulated: 22, available: 0, weight: 10, ineligibleUntil: 0}
2: {accumulated: 22, available: 0, weight: 20, ineligibleUntil: event5-time + 10000}
}
event 10) Withdraw Ineligable Rewards
globalRewardAccumulator: 22
rewardRoundingDust: 24
ineligibleEarnedRewards: 0
operatorRewards: {
1: {accumulated: 22, available: 0, weight: 10, ineligibleUntil: 0}
2: {accumulated: 22, available: 0, weight: 20, ineligibleUntil: event5-time + 10000}
}
return: 200