Staker reward mathematics
Plouto uses a cumulative reward-per-weight accumulator — the standard MasterChef-style pattern — with two additions: rewards are ETH rather than a minted token, and there is an explicit rollover rule for distributions that arrive while nothing is staked.
The accumulator
One global variable tracks reward per unit of Gravity, scaled to survive integer division:
uint256 private constant ACC_PRECISION = 1e27;uint256 public accRewardPerWeight;When a distribution arrives:
accRewardPerWeight += (amount * ACC_PRECISION) / totalWeight;Reward debt
Each position stores a rewardDebt: the amount it would already have been owed if it had existed since the accumulator was zero. It is set when the position opens:
rewardDebt = (weight * accRewardPerWeight) / ACC_PRECISION;Pending rewards are then simply:
pending = (weight × accRewardPerWeight) / ACC_PRECISION − rewardDebtBecause rewardDebt captures the accumulator at open time, a new position cannot claim historical rewards. This is asserted directly in the test suite: stake, distribute, stake again, and the second position's pending balance is exactly zero.
Worked example
Two stakers, one distribution.
| Amount | Lock | Gravity | |
|---|---|---|---|
| Alice | 100 PLOUTO | 7 days | 100 |
| Bob | 100 PLOUTO | 90 days | 250 |
totalWeight = 350.
10 ETH of fee revenue is claimed and routed. The staker share is 30%, so 3 ETH reaches notifyRewardETH().
accRewardPerWeight += (3e18 × 1e27) / 350e18 = 8,571,428,571,428,571,428,571,428 (approx)Alice pending = (100e18 × acc) / 1e27 − 0 ≈ 0.857142857142857142 ETHBob pending = (250e18 × acc) / 1e27 − 0 ≈ 2.142857142857142857 ETH ───────────────────── total ≈ 2.999999999999999999 ETHThe missing wei is rounding, and it rounds in the protocol's favour — see below.
Rounding always favours solvency
Every division rounds down. The consequence is that the contract can only ever owe less than it holds, never more. Two invariants make this explicit:
totalRewardsClaimed <= totalRewardsReceivedaddress(staking).balance >= totalRewardsReceived - totalRewardsClaimed
Dust accumulates in the contract over time. It is not lost — it remains distributable to future stakers — but it is not individually recoverable.
The zero-weight rollover
If a distribution arrives when totalWeight == 0, dividing by it would revert. Plouto holds the amount instead:
if (totalWeight == 0) { pendingUndistributed = amount;} else { pendingUndistributed = 0; accRewardPerWeight += (amount * ACC_PRECISION) / totalWeight;}Held rewards are flushed into the accumulator on the next stake. The ordering here is subtle and was a real bug during development:
// Snapshot BEFORE any flush. Everything accrued so far is historical.uint256 accSnapshot = accRewardPerWeight; totalStaked += amount;totalWeight += weight; // Flush AFTER this position is counted, so the first staker to appear// actually receives the rollover._flushPending(); // ... rewardDebt uses the pre-flush snapshot:rewardDebt: (weight * accSnapshot) / ACC_PRECISIONBecause pendingUndistributed can only be non-zero while totalWeight == 0, flushing after the new weight is counted can never dilute an existing staker — there are none.
Claiming does not unlock
claim(positionId) and claimMany(ids) pay out ETH and update rewardDebt. They do not touch amount, weight or unlockAt. A position can be harvested any number of times during its lock.
Harvesting is effects-only; the transfer happens after all state is written, and the whole function is nonReentrant.
Forfeited rewards recirculate
If a staker takes the emergency exit, their unclaimed rewards are forfeited — and folded back into the accumulator for everyone still staked:
if (forfeited != 0) { if (totalWeight != 0) { accRewardPerWeight += (forfeited * ACC_PRECISION) / totalWeight; } else { pendingUndistributed += forfeited; }}Nothing is captured by an admin. See emergency withdrawal.