← Back to Blog

Staking Contract Security: Reward Manipulation, Flash Loan Attacks, and Accounting Bugs

2026-04-18 staking solidity security reward manipulation defi yield farming 2026

Staking contracts look simple: users deposit tokens, rewards accumulate over time, users withdraw principal plus earnings. Yet staking contracts have lost hundreds of millions to exploits — reward manipulation via flash loans, rounding errors that compound into insolvency, missing modifiers that allow double-claiming, and reentrancy via ERC-777 callbacks.

This post covers six vulnerability classes in production staking contracts, with vulnerable code, secure fixes, and detection notes for each.


How Staking Contracts Work

The canonical pattern, popularized by Synthetix, tracks rewards using a global rewardPerToken accumulator:

contract StakingRewards {
    IERC20 public stakingToken;
    IERC20 public rewardsToken;

    uint256 public rewardRate;          // tokens per second
    uint256 public lastUpdateTime;
    uint256 public rewardPerTokenStored;

    mapping(address => uint256) public userRewardPerTokenPaid;
    mapping(address => uint256) public rewards;
    mapping(address => uint256) private _balances;
    uint256 private _totalSupply;

    function rewardPerToken() public view returns (uint256) {
        if (_totalSupply == 0) return rewardPerTokenStored;
        return rewardPerTokenStored + (
            (block.timestamp - lastUpdateTime) * rewardRate * 1e18 / _totalSupply
        );
    }

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

rewardPerToken grows continuously. When a user stakes, their userRewardPerTokenPaid is snapshotted to the current value. earned() computes the delta between the current accumulator and their snapshot, scaled by their balance. Withdraw and harvest reset the snapshot.

This pattern is correct in principle. The bugs live in the edges.


Vulnerability 1: Flash Loan Stake-Harvest-Unstake

An attacker with a flash loan can stake an enormous amount in one block, call harvest() to claim rewards, then unstake — capturing a disproportionate share of the reward pool in a single transaction.

// VULNERABLE: no time-lock or snapshot delay

function stake(uint256 amount) external updateReward(msg.sender) {
    _totalSupply += amount;
    _balances[msg.sender] += amount;
    stakingToken.transferFrom(msg.sender, address(this), amount);
}

function harvest() external updateReward(msg.sender) {
    uint256 reward = rewards[msg.sender];
    if (reward > 0) {
        rewards[msg.sender] = 0;
        rewardsToken.transfer(msg.sender, reward);
    }
}

function withdraw(uint256 amount) external updateReward(msg.sender) {
    _totalSupply -= amount;
    _balances[msg.sender] -= amount;
    stakingToken.transfer(msg.sender, amount);
}

The attack flow:
1. Flash loan 10,000,000 staking tokens
2. Call stake() — attacker now owns 99.9% of _totalSupply
3. Call harvest() — captures ~99.9% of pending rewards
4. Call withdraw() — returns principal
5. Repay flash loan

The rewardPerToken accumulator does not care how long a position has been open. Anyone who holds a large share at the moment rewards are snapshotted captures that proportion.

Fix: minimum staking duration or snapshot-based reward delay

mapping(address => uint256) public stakeTimestamp;
uint256 public constant MIN_STAKE_DURATION = 1 days;

function harvest() external updateReward(msg.sender) {
    require(
        block.timestamp >= stakeTimestamp[msg.sender] + MIN_STAKE_DURATION,
        "Too early to harvest"
    );
    uint256 reward = rewards[msg.sender];
    if (reward > 0) {
        rewards[msg.sender] = 0;
        rewardsToken.transfer(msg.sender, reward);
    }
}

Alternatively, track rewards in a separate epoch and only make them claimable after a delay. The key property: any position must hold through at least one reward period before it can claim.


Vulnerability 2: Reward Accounting Rounding Error

Integer division truncates. In rewardPerToken, every second of accrual divides by _totalSupply. If rewardRate * elapsed < _totalSupply, the result rounds to zero and that second's rewards are permanently lost.

// VULNERABLE: precision loss when totalSupply is large

function rewardPerToken() public view returns (uint256) {
    if (_totalSupply == 0) return rewardPerTokenStored;
    return rewardPerTokenStored + (
        (block.timestamp - lastUpdateTime) * rewardRate / _totalSupply
        // ^ drops sub-unit precision entirely
    );
}

With rewardRate = 1e15 and _totalSupply = 1e24, each second contributes 1e15 / 1e24 = 0 in integer math. The protocol silently zeroes rewards that should accrue.

The inverse problem exists too: truncation in earned() means per-user dust never reaches the protocol. Over millions of users and long durations, this compounds.

Fix: scale by 1e18 before dividing

// SECURE: precision preserved via 1e18 scaling factor

uint256 public constant PRECISION = 1e18;

function rewardPerToken() public view returns (uint256) {
    if (_totalSupply == 0) return rewardPerTokenStored;
    return rewardPerTokenStored + (
        (block.timestamp - lastUpdateTime) * rewardRate * PRECISION / _totalSupply
    );
}

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

The PRECISION factor keeps intermediate values large enough that truncation error is sub-wei rather than sub-token. This is the standard pattern — it should be in every staking contract from day one.


Vulnerability 3: Missing updateReward Modifier

The rewardPerToken accumulator must be checkpointed every time a user's balance changes. If any state-changing function skips that checkpoint, the accounting breaks.

// VULNERABLE: withdraw() missing updateReward

modifier updateReward(address account) {
    rewardPerTokenStored = rewardPerToken();
    lastUpdateTime = block.timestamp;
    if (account != address(0)) {
        rewards[account] = earned(account);
        userRewardPerTokenPaid[account] = rewardPerTokenStored;
    }
    _;
}

function withdraw(uint256 amount) external {
    // No updateReward modifier — balance changes without checkpointing rewards
    _totalSupply -= amount;
    _balances[msg.sender] -= amount;
    stakingToken.transfer(msg.sender, amount);
}

function harvest() external updateReward(msg.sender) {
    uint256 reward = rewards[msg.sender];
    if (reward > 0) {
        rewards[msg.sender] = 0;
        rewardsToken.transfer(msg.sender, reward);
    }
}

If a user calls withdraw() before harvest(), their balance drops to zero without snapshotting the earned rewards. When harvest() runs, earned() returns near-zero because _balances[msg.sender] is already reduced. The user loses their accrued rewards.

The reverse is also possible: with certain state orderings, a user could claim the same rewards twice by interleaving calls with another account that shifts _totalSupply.

Fix: always apply updateReward to every balance-changing function

function withdraw(uint256 amount) external updateReward(msg.sender) {
    _totalSupply -= amount;
    _balances[msg.sender] -= amount;
    stakingToken.transfer(msg.sender, amount);
}

The modifier is a single line of protection. Omitting it from any public function that touches balances invalidates the entire accounting model.


Vulnerability 4: Reentrancy via ERC-777 Reward Token

ERC-777 tokens call tokensReceived on the recipient before the transfer completes. If the reward token is ERC-777, the harvest() function becomes a reentrancy vector.

// VULNERABLE: ERC-777 reward token with no reentrancy guard

function withdraw(uint256 amount) external updateReward(msg.sender) {
    _totalSupply -= amount;
    _balances[msg.sender] -= amount;
    stakingToken.transfer(msg.sender, amount);  // (1) principal returned
}

function harvest() external updateReward(msg.sender) {
    uint256 reward = rewards[msg.sender];
    if (reward > 0) {
        rewards[msg.sender] = 0;
        rewardsToken.transfer(msg.sender, reward);  // (2) ERC-777: calls tokensReceived
        // Attacker re-enters here. rewards[msg.sender] was zeroed,
        // but if they manipulate _balances via a nested stake(),
        // the next updateReward checkpoint creates fresh rewards.
    }
}

The classic pattern is: withdraw() then harvest(). Between steps, the ERC-777 hook re-enters stake() with the returned principal, inflating the attacker's balance before the outer harvest() checkpoints.

Fix: nonReentrant guard and effects-before-interactions

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

function harvest() external nonReentrant updateReward(msg.sender) {
    uint256 reward = rewards[msg.sender];
    if (reward > 0) {
        rewards[msg.sender] = 0;                        // effect first
        rewardsToken.transfer(msg.sender, reward);      // interaction second
    }
}

function withdraw(uint256 amount) external nonReentrant updateReward(msg.sender) {
    _totalSupply -= amount;
    _balances[msg.sender] -= amount;
    stakingToken.transfer(msg.sender, amount);
}

nonReentrant is mandatory whenever the reward or staking token could have hooks. Even if the current reward token is plain ERC-20, contracts are often upgraded or redeployed with different tokens later.


Vulnerability 5: Inflation Attack on First Deposit

When a staking pool has zero _totalSupply, the first depositor can manipulate rewardPerTokenStored by donating tokens directly to the contract rather than staking them.

// Attack sequence:
// 1. Pool launches with _totalSupply = 0
// 2. Attacker stakes 1 wei — now _totalSupply = 1
// 3. Attacker donates 1,000,000 tokens directly to the contract
//    (not via stake(), so _totalSupply stays at 1)
// 4. rewardPerToken() = rewardPerTokenStored + (elapsed * rewardRate * 1e18 / 1)
//    — the denominator is 1, so each second accrues enormous rewardPerToken
// 5. When victim stakes, their userRewardPerTokenPaid is set to the inflated value
//    — they immediately earn near-zero rewards despite depositing legitimately
// 6. Attacker harvests the accumulated rewards that were meant for the victim

This is the same share-inflation vector documented in ERC-4626 vaults. In staking contracts, the donation inflates rewardPerToken rather than the exchange rate, but the mechanics are identical.

Fix: virtual balance initialization and minimum stake

uint256 private _totalSupply;
uint256 private constant MINIMUM_LIQUIDITY = 1000;

constructor(...) {
    // Lock minimum liquidity at deployment, preventing 1-wei first-deposit attack
    _totalSupply = MINIMUM_LIQUIDITY;
    _balances[address(0)] = MINIMUM_LIQUIDITY;
}

Alternatively, restrict the first stake to the protocol deployer, or require a minimum deposit amount large enough to make donation attacks economically irrational.


Vulnerability 6: Admin Drain via notifyRewardAmount and recoverERC20

Many staking contracts grant the owner the ability to set reward rates and recover mistakenly sent tokens. These functions become drain vectors when implemented carelessly.

// VULNERABLE: owner can zero out reward rate mid-period
function notifyRewardAmount(uint256 reward) external onlyOwner updateReward(address(0)) {
    rewardRate = reward / rewardsDuration;
    // If owner calls with reward = 0, rewardRate = 0
    // All future rewards stop accruing — users who staked expecting yield get nothing
    lastUpdateTime = block.timestamp;
    periodFinish = block.timestamp + rewardsDuration;
    emit RewardAdded(reward);
}

// VULNERABLE: recoverERC20 does not exclude the staking token
function recoverERC20(address tokenAddress, uint256 tokenAmount) external onlyOwner {
    IERC20(tokenAddress).transfer(owner(), tokenAmount);
    // Owner can pass tokenAddress = address(stakingToken)
    // and drain all staked user principal
}

Both issues represent trusted admin paths that violate the trust model stakers assume when depositing.

Fix: guard both functions explicitly

function notifyRewardAmount(uint256 reward) external onlyOwner updateReward(address(0)) {
    require(reward > 0, "Reward must be positive");
    // Prevent reducing reward below what is already owed
    if (block.timestamp >= periodFinish) {
        rewardRate = reward / rewardsDuration;
    } else {
        uint256 remaining = periodFinish - block.timestamp;
        uint256 leftover = remaining * rewardRate;
        rewardRate = (reward + leftover) / rewardsDuration;
    }
    require(
        rewardRate <= rewardsToken.balanceOf(address(this)) / rewardsDuration,
        "Reward rate exceeds balance"
    );
    lastUpdateTime = block.timestamp;
    periodFinish = block.timestamp + rewardsDuration;
    emit RewardAdded(reward);
}

function recoverERC20(address tokenAddress, uint256 tokenAmount) external onlyOwner {
    require(tokenAddress != address(stakingToken), "Cannot recover staking token");
    IERC20(tokenAddress).transfer(owner(), tokenAmount);
    emit Recovered(tokenAddress, tokenAmount);
}

The single-line exclusion check in recoverERC20 is all that stands between users and total principal loss. It should be in every staking contract that exposes this function.


What ContractScan Finds

Vulnerability Slither AI Analysis
Missing updateReward on withdraw/stake Detects modifier absence on public functions Traces reward accounting flow across all entry points
Rounding errors in rewardPerToken Flags integer division without scaling Identifies precision loss magnitude with realistic values
No reentrancy guard on harvest reentrancy-eth / reentrancy-no-eth detectors Detects ERC-777 callback paths specifically
Flash loan stake-harvest-unstake Not detected Models multi-step same-block attack scenarios
First-deposit inflation Not detected Identifies uninitialized _totalSupply paths
recoverERC20 missing staking token exclusion Not detected Flags owner functions that touch staking token balance
notifyRewardAmount rate manipulation Not detected Evaluates owner privilege against stated trust model

Slither catches the structural patterns well. The economic attacks — flash loans, inflation, admin rate manipulation — require reasoning about multi-step interactions that static analysis cannot model without semantic context.


Minimal Secure Staking Implementation

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract SecureStaking is ReentrancyGuard, Ownable {
    using SafeERC20 for IERC20;

    IERC20 public immutable stakingToken;
    IERC20 public immutable rewardsToken;

    uint256 public rewardRate;
    uint256 public periodFinish;
    uint256 public rewardsDuration = 7 days;
    uint256 public lastUpdateTime;
    uint256 public rewardPerTokenStored;

    mapping(address => uint256) public userRewardPerTokenPaid;
    mapping(address => uint256) public rewards;
    mapping(address => uint256) private _balances;
    mapping(address => uint256) public stakeTimestamp;

    uint256 private _totalSupply;
    uint256 private constant PRECISION = 1e18;
    uint256 public constant MIN_STAKE_DURATION = 1 days;

    constructor(address _stakingToken, address _rewardsToken) Ownable(msg.sender) {
        stakingToken = IERC20(_stakingToken);
        rewardsToken = IERC20(_rewardsToken);
    }

    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;
    }

    function rewardPerToken() public view returns (uint256) {
        if (_totalSupply == 0) return rewardPerTokenStored;
        return rewardPerTokenStored + (
            (lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * PRECISION / _totalSupply
        );
    }

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

    function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
        require(amount > 0, "Cannot stake 0");
        _totalSupply += amount;
        _balances[msg.sender] += amount;
        stakeTimestamp[msg.sender] = block.timestamp;
        stakingToken.safeTransferFrom(msg.sender, address(this), amount);
    }

    function withdraw(uint256 amount) external nonReentrant updateReward(msg.sender) {
        require(amount > 0, "Cannot withdraw 0");
        _totalSupply -= amount;
        _balances[msg.sender] -= amount;
        stakingToken.safeTransfer(msg.sender, amount);
    }

    function harvest() external nonReentrant updateReward(msg.sender) {
        require(
            block.timestamp >= stakeTimestamp[msg.sender] + MIN_STAKE_DURATION,
            "Min stake duration not met"
        );
        uint256 reward = rewards[msg.sender];
        if (reward > 0) {
            rewards[msg.sender] = 0;
            rewardsToken.safeTransfer(msg.sender, reward);
        }
    }

    function notifyRewardAmount(uint256 reward) external onlyOwner updateReward(address(0)) {
        require(reward > 0, "Reward must be positive");
        if (block.timestamp >= periodFinish) {
            rewardRate = reward / rewardsDuration;
        } else {
            uint256 remaining = periodFinish - block.timestamp;
            uint256 leftover = remaining * rewardRate;
            rewardRate = (reward + leftover) / rewardsDuration;
        }
        require(
            rewardRate <= rewardsToken.balanceOf(address(this)) / rewardsDuration,
            "Reward rate exceeds balance"
        );
        lastUpdateTime = block.timestamp;
        periodFinish = block.timestamp + rewardsDuration;
    }

    function recoverERC20(address tokenAddress, uint256 tokenAmount) external onlyOwner {
        require(tokenAddress != address(stakingToken), "Cannot recover staking token");
        IERC20(tokenAddress).safeTransfer(owner(), tokenAmount);
    }
}

Key properties: updateReward on every balance-changing function, nonReentrant on all user-facing functions, PRECISION scaling, effects before interactions, MIN_STAKE_DURATION in harvest(), rate validation in notifyRewardAmount, and staking token exclusion in recoverERC20.


Audit Your Staking Contract

Staking contracts hold user principal for extended periods. Users expect deposits to be safe and rewards to accrue as advertised. A missing modifier, rounding error, or permissioned drain function each constitute a protocol failure.

Audit your staking contract at https://contract-scanner.raccoonworld.xyz. ContractScan runs Slither detection alongside AI-driven economic analysis to flag vulnerabilities that static analysis misses — flash loan attack paths, admin privilege abuse, and accounting invariant violations.


Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →