Skip to main content
Sigvex

Reward and Incentive Manipulation

Detects vulnerabilities in staking, yield farming, and reward distribution functions that allow attackers to inflate reward claims through flash loans, timestamp manipulation, or division-before-multiplication precision loss.

Reward and Incentive Manipulation

Overview

Remediation Guide: How to Fix Reward and Incentive Manipulation

The reward manipulation detector identifies vulnerabilities in staking, yield farming, and incentive distribution functions. These functions are high-value targets because they frequently calculate payouts based on instantaneous pool state — balance reads, totalSupply() calls, or block timestamps — that attackers can manipulate before the calculation executes.

Sigvex identifies three primary vulnerability patterns in reward-related functions (any function whose name contains keywords such as reward, claim, harvest, stake, deposit, withdraw, yield, earn, distribute, or incentive):

  1. Timestamp-based reward calculation: When block.timestamp is used in arithmetic operations to compute elapsed time and the result drives a reward payout. Validators can manipulate block timestamps within a 900-second window, and attackers can time transactions to maximize per-block rewards.

  2. Division before multiplication (precision loss): When reward math performs division before multiplication — for example, computing (elapsed / period) * rate — the intermediate division truncates fractional periods. In large-scale protocols this systematic rounding can be exploited to drain rewards or cause underpayment that accumulates over time.

  3. Flash loan-vulnerable pool state: When a reward function reads live pool state (storage balances, ETH.balance, or calls to totalSupply()) and distributes rewards based on that state without first committing the updated accounting to storage. An attacker can borrow assets via flash loan, temporarily inflate the pool, call the harvest function to claim inflated rewards, then repay the loan — all within a single transaction.

Why This Is an Issue

DeFi reward mechanisms have been exploited for $69M+ in annual losses:

  • Harvest Finance (2020): $34M. Attackers used flash loans to manipulate USDC/USDT pool ratios, then called the doHardWork() harvest function to collect inflated yields before repaying.
  • Cream Finance (2021): $130M. Reward calculation errors in the lending and reward distribution allowed repeated exploitation over multiple transactions.
  • Rari Capital (2022): $80M. Flash loan-driven manipulation of reward-bearing pool balances enabled disproportionate reward extraction.

The root cause in all three cases was that reward calculations were based on instantaneous, manipulable state rather than time-weighted or snapshotted values.

How to Resolve

// Before: Vulnerable — reward calculated from live pool state before state is updated
contract VulnerableStaking {
    IERC20 public rewardToken;
    mapping(address => uint256) public stakedAt;
    uint256 public totalStaked;

    function harvest() external {
        uint256 poolBalance = rewardToken.balanceOf(address(this));
        // VULNERABLE: poolBalance is manipulable via flash loan
        uint256 reward = (poolBalance * 1e18) / totalStaked;
        rewardToken.transfer(msg.sender, reward);
        // State updated after transfer — classic reentrancy + flash loan risk
    }
}

// After: Update accounting before reading pool state; use CEI pattern
contract SecureStaking {
    IERC20 public rewardToken;
    uint256 public rewardPerTokenStored;
    uint256 public lastUpdateTime;
    uint256 public totalStaked;
    mapping(address => uint256) public userRewardPerTokenPaid;
    mapping(address => uint256) public rewards;

    modifier updateReward(address account) {
        // Commit updated accounting BEFORE any external state read or transfer
        rewardPerTokenStored = rewardPerToken();
        lastUpdateTime = block.timestamp;
        if (account != address(0)) {
            rewards[account] = earned(account);
            userRewardPerTokenPaid[account] = rewardPerTokenStored;
        }
        _;
    }

    function rewardPerToken() public view returns (uint256) {
        if (totalStaked == 0) return rewardPerTokenStored;
        // Use stored accumulated rewards, not live pool balance
        return rewardPerTokenStored
            + ((block.timestamp - lastUpdateTime) * REWARD_RATE * 1e18) / totalStaked;
    }

    function earned(address account) public view returns (uint256) {
        return
            (stakedBalance[account] * (rewardPerToken() - userRewardPerTokenPaid[account])) / 1e18
            + rewards[account];
    }

    // CEI: update state first, then transfer
    function harvest() external updateReward(msg.sender) {
        uint256 reward = rewards[msg.sender];
        require(reward > 0, "No rewards");
        rewards[msg.sender] = 0;          // Effects: clear before transfer
        rewardToken.transfer(msg.sender, reward); // Interaction: transfer last
    }
}

Examples

Vulnerable Code

contract VulnerableYieldFarm {
    IERC20 public depositToken;
    IERC20 public rewardToken;
    mapping(address => uint256) public deposited;
    uint256 public totalDeposited;

    function deposit(uint256 amount) external {
        depositToken.transferFrom(msg.sender, address(this), amount);
        deposited[msg.sender] += amount;
        totalDeposited += amount;
    }

    function harvest() external {
        // VULNERABLE: uses live balance — manipulable by flash loan
        uint256 poolBalance = rewardToken.balanceOf(address(this));

        // VULNERABLE: division before multiplication causes precision loss
        // If poolBalance = 1000e18 and totalDeposited = 3000e18:
        //   deposited[msg] = 1000 → share = 1000/3000 = 0 (truncated)
        uint256 userShare = deposited[msg.sender] / totalDeposited;
        uint256 reward = userShare * poolBalance;

        // Transfer with no prior state update — reentrancy-prone
        rewardToken.transfer(msg.sender, reward);
    }
}

Fixed Code

contract SecureYieldFarm {
    IERC20 public depositToken;
    IERC20 public rewardToken;

    uint256 public constant PRECISION = 1e18;
    uint256 public rewardPerTokenAccumulated;
    uint256 public lastRewardTime;
    uint256 public rewardRatePerSecond;
    uint256 public totalDeposited;

    mapping(address => uint256) public deposited;
    mapping(address => uint256) public rewardDebt;
    mapping(address => uint256) public pendingRewards;

    modifier syncRewards(address user) {
        // Always commit reward accounting before any state change
        uint256 rpt = currentRewardPerToken();
        rewardPerTokenAccumulated = rpt;
        lastRewardTime = block.timestamp;
        if (user != address(0)) {
            pendingRewards[user] += (deposited[user] * (rpt - rewardDebt[user])) / PRECISION;
            rewardDebt[user] = rpt;
        }
        _;
    }

    function currentRewardPerToken() public view returns (uint256) {
        if (totalDeposited == 0) return rewardPerTokenAccumulated;
        uint256 elapsed = block.timestamp - lastRewardTime;
        // Multiply BEFORE dividing to preserve precision
        return rewardPerTokenAccumulated + (elapsed * rewardRatePerSecond * PRECISION) / totalDeposited;
    }

    function harvest() external syncRewards(msg.sender) {
        uint256 reward = pendingRewards[msg.sender];
        require(reward > 0, "Nothing to harvest");
        // Effects first
        pendingRewards[msg.sender] = 0;
        // Interaction last
        rewardToken.transfer(msg.sender, reward);
    }
}

Sample Sigvex Output

{
  "detector_id": "reward-manipulation",
  "severity": "high",
  "confidence": 0.70,
  "description": "Function harvest() reads pool state via rewardToken.balanceOf() without first updating reward accounting. An attacker can use a flash loan to temporarily inflate the pool balance, call harvest() to claim inflated rewards, and repay the loan in a single transaction. This pattern matches the $34M Harvest Finance exploit.",
  "location": { "function": "harvest()", "offset": 0 }
}

Detection Methodology

Sigvex identifies reward manipulation vulnerabilities through the following steps:

  1. Function scope filtering: Identifies reward-related functions by matching function names against a keyword list (reward, claim, harvest, stake, deposit, withdraw, yield, earn, distribute, mint, incentive). Only these functions are analyzed.
  2. Pattern 1 — Timestamp arithmetic: Checks whether block.timestamp is stored in a variable that then appears as an operand in an arithmetic operation (add, subtract, multiply, divide, modulo). Confidence: 0.70.
  3. Pattern 2 — Division-before-multiplication: Tracks division result variables and flags any subsequent multiplication that uses a division result as an operand. Confidence: 0.78 (without overflow protection); reduced when Solidity 0.8+ overflow semantics or SafeMath is detected.
  4. Pattern 3 — Flash loan-vulnerable pool state: Checks whether the function reads live pool state (storage load, BALANCE, SELFBALANCE, or external calls) and then performs an external call (transfer or send) without an intervening storage write that commits the updated reward accounting. Confidence: 0.70.

Context modifiers:

  • Known audited library (OpenZeppelin): confidence multiplied by 0.3
  • Reentrancy guard present: confidence multiplied by 0.5 (guards against flash loan reentrancy)
  • Access control present: confidence multiplied by 0.7
  • Standard token contract (ERC-20/721/1155): confidence multiplied by 0.4
  • Proxy contract: confidence multiplied by 0.4 (logic may be in implementation)

Limitations

False positives:

  • Protocols that use time-locked or multi-block reward windows are flagged for timestamp use even when a single-block window provides no meaningful manipulation advantage.
  • Contracts using OpenZeppelin’s ReentrancyGuard and audited Synthetix-style staking math (already multiply-before-divide) may receive reduced-confidence findings that are true negatives.

False negatives:

  • Flash loan protection implemented via a custom non-standard reentrancy guard (e.g., a sentinel storage slot compared at function entry) is not always recognized.
  • Protocols where pool state is read from a separate oracle or aggregator contract that internally uses snapshots may be flagged because the external call pattern matches the live-state read signature.

References