Staking and rewards distribution: fuzzing the accounting protocols get wrong
Staking and Rewards Distribution: Fuzzing the Accounting Protocols Get Wrong
By nican0r
Staking contracts look simple. Users deposit tokens, time passes, rewards accrue. But under the hood, the math is a minefield. I've lost count of how many staking implementations I've fuzzed that had reward calculation bugs — and most of them passed thorough unit test suites with flying colors.
The problem isn't that developers don't understand the math. It's that the math interacts with time, ordering, and precision in ways that are almost impossible to reason about manually. That's exactly why invariant testing exists. Let's build a complete property suite for staking and rewards protocols.
The reward-per-token accumulator pattern
Almost every staking protocol uses the "reward-per-token" accumulator pattern (popularized by Synthetix). The idea: instead of tracking each user's rewards individually, you maintain a global accumulator that increases over time. Each user tracks their snapshot of it.
Here's the bug magnet: the accumulator update involves division. In Solidity, division truncates, so multiply-then-divide vs divide-then-multiply gives different results. This is where rounding errors become real exploits.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
contract StakingInvariantTest is Test {
StakingPool pool;
StakingHandler handler;
function setUp() public {
pool = new StakingPool(rewardToken, stakingToken);
handler = new StakingHandler(pool);
targetContract(address(handler));
}
/// @notice Total rewards claimed + pending must never exceed total distributed
function invariant_rewards_conservation() public view {
uint256 totalClaimed = handler.totalRewardsClaimed();
uint256 totalPending = handler.sumOfAllPendingRewards();
uint256 totalDistributed = pool.totalRewardsDistributed();
assertLe(
totalClaimed + totalPending,
totalDistributed + handler.numUsers(), // 1 wei rounding tolerance per user
"More rewards claimed than distributed"
);
}
/// @notice No user can claim rewards they didn't earn
function invariant_no_reward_theft() public view {
for (uint256 i = 0; i < handler.numUsers(); i++) {
address user = handler.users(i);
uint256 userClaimed = handler.userTotalClaimed(user);
uint256 maxPossibleReward = handler.maxRewardForUser(user);
assertLe(
userClaimed,
maxPossibleReward + 1, // 1 wei tolerance
"User claimed more than max possible reward"
);
}
}
}
That maxRewardForUser ghost variable is doing heavy lifting. Your handler needs to track each user's staking duration and the reward rate during that period to compute the theoretical maximum they could've earned.
Reward rate consistency
The reward rate tells you how many tokens get distributed per second (or per block). This rate should behave predictably:
/// @notice Reward rate must match configured emission schedule
function invariant_reward_rate_bounded() public view {
uint256 currentRate = pool.rewardRate();
if (block.timestamp < pool.periodFinish()) {
assertGt(currentRate, 0, "Rate is zero during active period");
assertLe(
currentRate,
pool.maxRewardRate(),
"Rate exceeds configured maximum"
);
} else {
// After period ends, rate should effectively be zero
// (some implementations keep the rate but stop accruing)
}
}
/// @notice Total distributed over time must not exceed reward balance
function invariant_emission_solvency() public view {
uint256 remainingTime = pool.periodFinish() > block.timestamp
? pool.periodFinish() - block.timestamp
: 0;
uint256 futureEmissions = pool.rewardRate() * remainingTime;
uint256 rewardBalance = rewardToken.balanceOf(address(pool));
uint256 pendingClaims = handler.sumOfAllPendingRewards();
assertLe(
futureEmissions + pendingClaims,
rewardBalance + handler.numUsers(), // rounding tolerance
"Pool is insolvent -- can't cover future emissions + pending claims"
);
}
That solvency invariant is critical. I've seen pools where calling notifyRewardAmount with the wrong value created a reward rate that would overdistribute. Users who claimed early got paid; late claimers got nothing. Classic bank run bug.
Stake/unstake round-trip property
A user who stakes and immediately unstakes (same block, no time passes) should get back exactly what they put in. No more, no less. Any deviation means something's wrong with the deposit/withdrawal math.
/// @notice Stake then unstake in same block returns exact amount
function test_stake_unstake_round_trip(uint256 amount) public {
amount = bound(amount, 1, 1e30);
deal(address(stakingToken), address(this), amount);
stakingToken.approve(address(pool), amount);
uint256 balanceBefore = stakingToken.balanceOf(address(this));
pool.stake(amount);
pool.unstake(amount);
uint256 balanceAfter = stakingToken.balanceOf(address(this));
assertEq(balanceAfter, balanceBefore, "Round trip lost tokens");
}
/// @notice Total staked must equal sum of all user stakes
function invariant_total_staked_accounting() public view {
uint256 sumOfStakes;
for (uint256 i = 0; i < handler.numUsers(); i++) {
sumOfStakes += pool.balanceOf(handler.users(i));
}
assertEq(
sumOfStakes,
pool.totalSupply(),
"Individual stakes don't sum to totalSupply"
);
}
Reward distribution proportionality
This is the property most staking protocols get subtly wrong. If Alice stakes 75% of the pool and Bob stakes 25%, Alice should earn roughly 75% of the rewards (within rounding tolerance).
/// @notice Rewards must distribute proportionally to stake weight
function invariant_proportional_distribution() public view {
if (pool.totalSupply() == 0) return;
if (handler.totalTimeElapsed() == 0) return;
for (uint256 i = 0; i < handler.numUsers(); i++) {
address user = handler.users(i);
uint256 userTimeWeightedStake = handler.userTimeWeightedStake(user);
uint256 totalTimeWeightedStake = handler.totalTimeWeightedStake();
if (totalTimeWeightedStake == 0) continue;
uint256 expectedShare = (handler.totalRewardsDistributed()
* userTimeWeightedStake) / totalTimeWeightedStake;
uint256 actualRewards = handler.userTotalClaimed(user)
+ pool.earned(user);
// Allow 0.1% tolerance for rounding
uint256 tolerance = expectedShare / 1000 + 1;
assertLe(
actualRewards > expectedShare
? actualRewards - expectedShare
: expectedShare - actualRewards,
tolerance,
"Disproportionate reward distribution"
);
}
}
The time-weighted stake tracking in the handler is essential. You can't just check current balances. You need the integral of each user's stake over time. This is where the ghost variable pattern really earns its keep.
Time-weighted balance tracking
For protocols that use time-weighted balances (vesting, boost multipliers, etc.), you need properties that verify the time math:
/// @notice Time-weighted balance must increase monotonically while staked
function invariant_time_weighted_monotonic() public view {
for (uint256 i = 0; i < handler.numUsers(); i++) {
address user = handler.users(i);
if (pool.balanceOf(user) > 0) {
assertGe(
pool.timeWeightedBalance(user),
handler.previousTimeWeightedBalance(user),
"Time-weighted balance decreased while staked"
);
}
}
}
Cliff and vesting schedule properties
Many staking protocols lock rewards behind vesting schedules. These state machines are bug-prone because they combine time logic with accounting logic.
/// @notice No rewards claimable before cliff period ends
function invariant_cliff_enforced() public view {
for (uint256 i = 0; i < handler.numUsers(); i++) {
address user = handler.users(i);
uint256 stakeTimestamp = pool.stakeTimestamp(user);
if (stakeTimestamp > 0
&& block.timestamp < stakeTimestamp + pool.cliffDuration()
) {
assertEq(
pool.claimableRewards(user),
0,
"Rewards claimable before cliff"
);
}
}
}
/// @notice Vested amount must follow linear schedule after cliff
function invariant_linear_vesting_correctness() public view {
for (uint256 i = 0; i < handler.numUsers(); i++) {
address user = handler.users(i);
uint256 stakeTime = pool.stakeTimestamp(user);
uint256 cliff = pool.cliffDuration();
uint256 vestingDuration = pool.vestingDuration();
if (stakeTime == 0) continue;
if (block.timestamp < stakeTime + cliff) continue;
uint256 elapsed = block.timestamp - stakeTime - cliff;
uint256 totalRewards = pool.totalEarned(user);
uint256 expectedVested;
if (elapsed >= vestingDuration) {
expectedVested = totalRewards;
} else {
expectedVested = (totalRewards * elapsed) / vestingDuration;
}
uint256 actualClaimable = pool.claimableRewards(user);
// 1 wei per second tolerance for rounding
uint256 tolerance = elapsed + 1;
assertLe(
actualClaimable > expectedVested
? actualClaimable - expectedVested
: expectedVested - actualClaimable,
tolerance,
"Vesting schedule calculation incorrect"
);
}
}
Common bugs these properties catch
Let me walk through the bugs I see most often when fuzzing staking contracts:
1. Division-before-multiplication in rewardPerToken
This is the classic. The Synthetix rewardPerToken() formula is:
// WRONG (loses precision)
rewardPerTokenStored + (rewardRate * timeElapsed / totalSupply)
// RIGHT (multiply first)
rewardPerTokenStored + (rewardRate * timeElapsed * 1e18 / totalSupply)
Without the scaling factor, small staking pools lose almost all their reward precision. The proportionality invariant catches this because one user ends up with way more or way less than their fair share.
2. Reward drainage via flash-stake
Some pools don't checkpoint rewards properly. A user can flash-loan tokens, stake them (which snapshots a huge balance), then unstake in the same transaction. If earned() uses current balance instead of time-weighted balance, they steal accumulated rewards.
The no-reward-theft invariant catches this because the user's claimed rewards exceed their maxRewardForUser (they hadn't staked long enough to earn that much).
3. Notifying rewards on empty pool
When notifyRewardAmount gets called while totalSupply == 0, rewards accrue to nobody. Those tokens are locked forever. The emission solvency invariant catches the downstream effect: remaining rewards don't cover future emissions because some tokens went into a black hole.
4. Unstake reentrancy
If the staking token has a callback (ERC-777, or any hook), unstaking can re-enter. The total-staked-accounting invariant breaks because totalSupply is updated but user balance isn't (or vice versa) mid-reentrancy.
Building the handler
Your staking handler needs these actions at minimum:
contract StakingHandler is CommonBase, StdCheats, StdUtils {
// Actions the fuzzer can call
function stake(uint256 userSeed, uint256 amount) external {
address user = _selectUser(userSeed);
amount = bound(amount, 1, 1e24);
_mintAndApprove(user, amount);
vm.prank(user);
pool.stake(amount);
_updateGhostVariables(user);
}
function unstake(uint256 userSeed, uint256 amount) external {
address user = _selectUser(userSeed);
uint256 staked = pool.balanceOf(user);
if (staked == 0) return;
amount = bound(amount, 1, staked);
vm.prank(user);
pool.unstake(amount);
_updateGhostVariables(user);
}
function claimRewards(uint256 userSeed) external {
address user = _selectUser(userSeed);
uint256 pending = pool.earned(user);
vm.prank(user);
pool.claimReward();
totalRewardsClaimed += pending;
userTotalClaimed[user] += pending;
}
function advanceTime(uint256 seconds_) external {
seconds_ = bound(seconds_, 1, 7 days);
vm.warp(block.timestamp + seconds_);
_snapshotTimeWeightedBalances();
}
}
The advanceTime action is critical. Without it, your fuzzer only tests same-block interactions. Time is a first-class input for staking protocols, so treat it that way.
Emission schedule bounds
For protocols with fixed emission schedules (halving, decay curves, etc.), add properties that verify the schedule:
/// @notice Total emissions must never exceed supply cap
function invariant_emission_cap() public view {
assertLe(
pool.totalRewardsDistributed(),
pool.maxTotalEmissions(),
"Emissions exceeded supply cap"
);
}
/// @notice Current epoch rate must match schedule
function invariant_epoch_rate_correct() public view {
uint256 currentEpoch = pool.currentEpoch();
uint256 expectedRate = pool.scheduledRate(currentEpoch);
assertEq(
pool.rewardRate(),
expectedRate,
"Reward rate doesn't match epoch schedule"
);
}
Where to start
If you're testing a staking contract for the first time, start with three properties:
- Rewards conservation: claimed + pending <= distributed
- Total staked accounting: sum of balances == totalSupply
- Stake/unstake round trip: no tokens lost on immediate withdrawal
These three catch the majority of staking security issues. Add proportionality and solvency next. Vesting and schedule properties come last. They're important but less likely to have critical bugs.
For more on writing your first invariant test, check out how to write your first invariant test. And if you're working with a protocol that combines staking with vault mechanics, fuzzing ERC-4626 vaults covers the overlap.
Need a full property suite for your staking protocol? Request an Audit or Try Recon Pro to auto-generate invariant tests for your contracts.
Related Posts
Postmortem: The Lending Protocol Reentrancy That Fuzzing Missed — And Invariants Didn't
The dev team ran Echidna for 24 hours: zero findings. The same vulnerability was found by invariant ...
5 Properties Every Smart Contract Auditor Forgets to Test
After 40+ DeFi audits, the same five invariant gaps come up every time. Not the obvious ones — accou...