Remediating Reward and Incentive Manipulation
How to protect staking and yield farming contracts against flash loan pool manipulation, timestamp gaming, and division-before-multiplication precision loss.
Remediating Reward and Incentive Manipulation
Overview
Related Detector: Reward and Incentive Manipulation
Reward manipulation vulnerabilities share a root cause: reward calculations consume instantaneous, manipulable state at the moment of the harvest call rather than state that was committed at a prior point in time. The three primary patterns — flash loan pool manipulation, timestamp-based gaming, and division-before-multiplication precision loss — each require a distinct fix, but all three share the same high-level remedy: commit reward accounting before any external call, and derive reward amounts from accumulated snapshots rather than live values.
Recommended Fix
Core Pattern: Accumulated Reward-Per-Token
The Synthetix staking rewards model is the industry-standard protection against all three manipulation vectors:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureStakingRewards is ReentrancyGuard {
IERC20 public immutable stakingToken;
IERC20 public immutable rewardToken;
uint256 public constant PRECISION = 1e18;
uint256 public rewardRatePerSecond;
uint256 public periodFinish;
uint256 public lastUpdateTime;
uint256 public rewardPerTokenStored;
mapping(address => uint256) public stakedBalance;
mapping(address => uint256) public userRewardPerTokenPaid;
mapping(address => uint256) public rewards;
uint256 public totalSupply;
// Update accumulated reward accounting BEFORE any state change or transfer
modifier updateReward(address account) {
rewardPerTokenStored = rewardPerToken();
lastUpdateTime = lastTimeRewardApplicable();
if (account != address(0)) {
rewards[account] = earned(account);
userRewardPerTokenPaid[account] = rewardPerTokenStored;
}
_;
}
function lastTimeRewardApplicable() public view returns (uint256) {
return block.timestamp < periodFinish ? block.timestamp : periodFinish;
}
// Multiply BEFORE dividing — always use precision multiplier to avoid truncation
function rewardPerToken() public view returns (uint256) {
if (totalSupply == 0) return rewardPerTokenStored;
return rewardPerTokenStored
+ ((lastTimeRewardApplicable() - lastUpdateTime) * rewardRatePerSecond * PRECISION)
/ totalSupply;
}
function earned(address account) public view returns (uint256) {
return (stakedBalance[account] * (rewardPerToken() - userRewardPerTokenPaid[account]))
/ PRECISION
+ rewards[account];
}
// CEI: checks first, then effects (state), then interactions (transfer)
function harvest() external nonReentrant updateReward(msg.sender) {
uint256 reward = rewards[msg.sender];
require(reward > 0, "No reward to claim");
rewards[msg.sender] = 0; // Effect
rewardToken.transfer(msg.sender, reward); // Interaction
}
function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
require(amount > 0, "Cannot stake 0");
totalSupply += amount;
stakedBalance[msg.sender] += amount;
stakingToken.transferFrom(msg.sender, address(this), amount);
}
function withdraw(uint256 amount) external nonReentrant updateReward(msg.sender) {
require(amount > 0, "Cannot withdraw 0");
require(amount <= stakedBalance[msg.sender], "Insufficient balance");
totalSupply -= amount;
stakedBalance[msg.sender] -= amount;
stakingToken.transfer(msg.sender, amount);
}
}
This pattern eliminates all three attack vectors:
- Flash loan resistance:
updateRewardcommits the accumulatedrewardPerTokenStoredto storage before any external interaction. A flash loan that inflatestotalSupplyduringharvest()cannot retroactively change the accumulated value that was already snapshotted. - Timestamp precision: Timestamp differences drive an additive accumulator, not a direct multiplier on live pool balance. The 900-second validator manipulation window produces a negligible delta in the accumulator.
- Precision preservation:
rewardRatePerSecond * PRECISIONis computed before the division bytotalSupply, ensuring precision is maintained at 18 decimal places.
Alternative Mitigations
Time-Weighted Average Balance
For AMM-style protocols that cannot use the accumulator pattern, compute rewards from time-weighted average balances (TWAB) rather than instantaneous balances:
contract TwabRewards {
struct Observation {
uint256 timestamp;
uint256 cumulativeBalance;
}
mapping(address => Observation[]) public observations;
function _record(address user, uint256 balance) internal {
observations[user].push(Observation({
timestamp: block.timestamp,
cumulativeBalance: _lastCumulative(user) + balance * (block.timestamp - _lastTimestamp(user))
}));
}
function getAverageBalance(address user, uint256 startTime, uint256 endTime)
public
view
returns (uint256)
{
uint256 startCumulative = _interpolateCumulative(user, startTime);
uint256 endCumulative = _interpolateCumulative(user, endTime);
return (endCumulative - startCumulative) / (endTime - startTime);
}
// Reward based on TWAB — flash loans have zero average effect over any meaningful period
function computeReward(address user) external view returns (uint256) {
uint256 avgBalance = getAverageBalance(user, rewardPeriodStart, rewardPeriodEnd);
return (avgBalance * totalRewardPool) / totalAverageDeposited;
}
}
Minimum Snapshot Delay
If TWAB is too expensive, require a minimum time between the staking action and the reward claim. A flash loan executes within a single block, so a one-block minimum delay completely eliminates the flash loan vector:
mapping(address => uint256) public lastStakeBlock;
function stake(uint256 amount) external {
// ... stake logic
lastStakeBlock[msg.sender] = block.number;
}
function harvest() external {
require(block.number > lastStakeBlock[msg.sender], "Must wait at least one block");
// ... harvest logic
}
Common Mistakes
Mistake: Calling balanceOf in the Reward Calculation
// WRONG: live balance is manipulable by flash loan
function harvest() external {
uint256 poolBalance = rewardToken.balanceOf(address(this));
uint256 reward = (stakedBalance[msg.sender] * poolBalance) / totalStaked;
rewardToken.transfer(msg.sender, reward);
}
Pool balance reads should never drive reward calculations directly. Use an accumulated per-token value derived from a fixed emission rate.
Mistake: Division Before Multiplication
// WRONG: precision lost in first division
uint256 share = stakedBalance[user] / totalStaked; // Could be 0 for small balances
uint256 reward = share * rewardPool; // Always 0 if share rounded to 0
Always multiply before dividing:
// CORRECT: full precision maintained
uint256 reward = (stakedBalance[user] * rewardPool) / totalStaked;
Mistake: Updating State After Transfer
// WRONG: state updated after external call — reentrancy and ordering issue
function harvest() external {
uint256 reward = computeReward(msg.sender);
rewardToken.transfer(msg.sender, reward); // External call first
rewards[msg.sender] = 0; // State update too late
}
Always follow Checks-Effects-Interactions: clear the reward balance before the transfer.