# 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.

- **Published:** 2026-05-19
- **Author:** Clixo
- **Reading time:** 6 min read
- **Tags:** staking, reward-distribution, defi, solidity, protocol-engineering
- **Canonical URL:** https://clixo.sh/blog/on-chain-staking-reward-distribution-design

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:

1. Call `_updatePool()` to bring `accRewardPerShare` current
2. Calculate any pending rewards for the user and transfer them (or record them as owed)
3. Add the deposit to `userStake` and to `totalStaked`
4. Set `rewardDebt = newUserStake * accRewardPerShare / PRECISION`

When a user withdraws:

1. Call `_updatePool()` to bring `accRewardPerShare` current
2. Calculate pending rewards and transfer them
3. Subtract from `userStake` and `totalStaked`
4. Update `rewardDebt` for the remaining position

```mermaid
sequenceDiagram
  participant U as User
  participant SC as Staking contract
  participant RT as Reward token
  U->>SC: deposit(amount)
  SC->>SC: _updatePool() — advance accRewardPerShare
  SC->>SC: pending = stake * accPerShare - rewardDebt
  SC->>RT: transfer(pending) to user
  SC->>SC: userStake += amount, totalStaked += amount
  SC->>SC: rewardDebt = newStake * accPerShare
  Note over SC: withdraw follows same pattern in reverse
```

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:

- `accToken0PerShare`
- `accToken1PerShare`
- 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](https://clixo.sh/#contact).

---

Clixo · 1141 W Bryn Mawr Ave, Itasca, IL 60143, US · [hello@clixo.sh](mailto:hello@clixo.sh)
[Start a build](https://clixo.sh/#contact) · [All services](https://clixo.sh/services) · [Agent guide (llms.txt)](https://clixo.sh/llms.txt)
