On-Chain Staking Reward Distribution: How to Design It Correctly
Staking reward distribution design: per-share accumulator math, checkpoints, multiple reward tokens, and edge cases to test.
A per-share accumulator can track staking rewards without updating every staker on each accrual. Correctness depends on checkpointing before balance or rate changes, handling empty pools, and testing integer rounding.
Why Naive Reward Accounting Fails
The simplest mental model for staking rewards: at regular intervals, divide the total reward by the number of stakers and credit each one. The implementation problems:
- It does not scale. Iterating over stakers to distribute rewards requires gas proportional to staker count. At a few thousand stakers, distribution transactions will exceed block gas limits.
- Partial periods are complex. If a user joins mid-period, their reward for the current period should be proportional to time staked. Tracking this for every staker is state-intensive.
- Changing stakes are hard to handle. If a user adds or removes stake mid-period, their reward rate changes. Recalculating everyone's balance whenever any staker's position changes does not work.
A global accumulator is one approach to avoiding a loop over every staker.
The Per-Share Accumulator Model
Instead of tracking what each staker is owed, track a global variable: the cumulative rewards per share from the beginning of the protocol to now. Call it accRewardPerShare.
Every time the reward state needs to update (before any deposit, withdrawal, or claim), compute how much has accumulated since the last update:
elapsed = block.timestamp - lastUpdateTime
newRewards = rewardRate * elapsed
accRewardPerShare += newRewards * PRECISION / totalStaked
lastUpdateTime = block.timestamp
PRECISION is a scaling factor (typically 1e12 or 1e18) that preserves precision in integer arithmetic.
Each user position stores a rewardDebt — a snapshot of accRewardPerShare adjusted for their stake at the time of their last interaction:
rewardDebt = userStake * accRewardPerShare / PRECISION
Pending rewards for a user at any time:
pending = (userStake * accRewardPerShare / PRECISION) - rewardDebt
This is O(1) per user, regardless of total staker count. The global accumulator does the accounting work for everyone simultaneously.
On-Chain Staking Reward Distribution: The Deposit and Withdrawal Flow
When a user deposits:
- Call
_updatePool()to bringaccRewardPerSharecurrent - Calculate any pending rewards for the user and transfer them (or record them as owed)
- Add the deposit to
userStakeand tototalStaked - Set
rewardDebt = newUserStake * accRewardPerShare / PRECISION
When a user withdraws:
- Call
_updatePool()to bringaccRewardPerSharecurrent - Calculate pending rewards and transfer them
- Subtract from
userStakeandtotalStaked - Update
rewardDebtfor the remaining position
The _updatePool() call at the start of every interaction is not optional. Skipping it means the accumulator is stale, and the user receives rewards calculated at an outdated rate.
Precision and Rounding
The precision factor choice is critical. If PRECISION is too small, rounding errors in the accumulator compound over time. If it is too large, multiplications involving userStake * accRewardPerShare risk overflowing uint256.
The calculation below still requires bounds and overflow analysis for the configured rates, precision, and stake sizes:
// Check: userStake * PRECISION does not overflow
// userStake < 2^128 and PRECISION = 1e12 → product < 2^168, safe
For reward tokens with 18 decimals and stakes in similar magnitude, PRECISION = 1e12 is standard. If your staking token has fewer decimals or if stakes can be very large, adjust and document the overflow analysis.
Always round pending rewards down (in favor of the protocol, not the user). Accumulated rounding error that favors the user can be drained by an attacker who repeatedly stakes and unstakes tiny amounts, extracting the rounding residue.
Multi-Token Reward Distribution
Many protocols distribute rewards in multiple tokens simultaneously (e.g., protocol token + partner token + ETH). Each reward token requires its own accumulator:
accToken0PerShareaccToken1PerShare- And so on
Each has its own rewardRate and its own rewardDebt stored per user position. The per-share accumulator model extends cleanly to multiple tokens — you just replicate the accounting for each one.
The implementation complexity grows linearly with reward token count. Beyond 3–4 simultaneous reward tokens, UX and gas costs become significant concerns for users who want to claim.
Checkpoint Design for Variable Reward Rates
If your protocol allows governance to change the reward rate, you must checkpoint the accumulator before applying the new rate. Failing to checkpoint means the new rate is retroactively applied to the period since the last update, which over- or under-pays LPs who staked during that period.
A safe rate change flow:
function setRewardRate(uint256 newRate) external onlyGovernance {
_updatePool(); // finalize current rate through now
rewardRate = newRate; // apply new rate going forward
emit RewardRateChanged(newRate);
}
This pattern ensures the accumulator is always calculated with the rate that was active during each period.
Edge Cases That Cause Losses
Zero total stake. When totalStaked is zero, the accumulator update would divide by zero. Guard against this: if totalStaked == 0, skip the accumulator update but still advance lastUpdateTime so reward tokens scheduled for that period are effectively forfeited or roll forward.
Whether to roll forward (accumulate rewards during empty periods) or forfeit them depends on the protocol design. Forfeiting is simpler and avoids a spike in rewards when the first staker arrives. Rolling forward means early stakers see inflated initial yields — which can be desirable for bootstrapping but creates a sharp discontinuity.
Very small stakes. A stake of 1 wei with high accRewardPerShare can produce a pending reward of zero due to rounding. This is expected behavior, but ensure users cannot spam tiny deposits to probe for rounding residue.
Late reward funding. If the reward token is not funded in the contract before the reward period starts, the accumulator will run normally but transfer calls will revert. Either pre-fund the contract before activating rewards or implement a check that pauses reward accrual when the balance is insufficient.
Pausing during active positions. If you pause reward accrual (for example, during an emergency), ensure lastUpdateTime is also paused. Otherwise, when you resume, a large time gap will cause a spike in accRewardPerShare that over-distributes rewards for the pause period.