Skip to main content
Sigvex

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.

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 "@audited/token/ERC20/IERC20.sol";
import "@audited/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: updateReward commits the accumulated rewardPerTokenStored to storage before any external interaction. A flash loan that inflates totalSupply during harvest() 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 * PRECISION is computed before the division by totalSupply, 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.

References