On-Chain Staking Reward Distribution: How to Design It Correctly
How on-chain staking reward distribution actually works — per-share accumulator math, checkpoint design, multi-token rewards, and the edge cases that cause protocol losses.
Staking reward distribution looks straightforward until you try to implement it correctly at scale. The naive approaches — storing rewards per user, iterating over stakers, recalculating everything on every interaction — either do not scale or produce incorrect results under edge conditions. This post covers the accounting model that production staking protocols use and the failure modes that trip teams up.
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.
The solution used by virtually every production staking protocol is a global accumulator.
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 safe calculation is:
// 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.
Reward distribution accounting is one of those areas where the first implementation almost always has a subtle bug that only surfaces at scale or under adversarial conditions. If you are building a staking protocol and want an engineering team that has designed and audited these systems, start a build with Clixo.