# Solidity Yield Staking Reward Drain

> Audit staking pools, MasterChef forks, Synthetics StakingRewards implementations, and reward distributors for reward debt miscalculations, reward frontrunning, flash-deposit harvesting, and reward rate dilution.

- Skill: `netvar1337/solidity-yield-staking-reward-drain` (Agent Skill)
- Install (CLI): `npx skillmds@latest add netvar1337/solidity-yield-staking-reward-drain`
- Raw SKILL.md: https://api.skillmd.com/api/skills/netvar1337/solidity-yield-staking-reward-drain/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: netvar1337 (https://skillmd.com/u/netvar1337)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/netvar1337/solidity-yield-staking-reward-drain

---


# Solidity Staking, Yield Farming & Reward Accounting Vulnerabilities

## Overview & Empirical Grounding
This security auditing skill is synthesized from **1044 empirical audit contest findings** (151 Critical, 893 High severity) extracted from the `Zaevlad/audit-findings-dataset` corpus.

Auditors and security reviewers must use this methodology when reviewing smart contracts that exhibit **Yield & Incentives** patterns.

### Key Trigger Keywords
`staking rewards`, `rewardpertoken`, `accrewardpershare`, `masterchef`, `synthetics staking`, `harvest frontrunning`, `reward debt`

---

## Threat Model & Core Attack Vectors

Detailed failure modes identified across empirical audit findings:
1. **Unchecked Assumptions & Protocol State Discrepancy**: Deviations between internal accounting state and actual balances/external conditions.
2. **Missing Boundary Checks & Validation**: Failing to constrain user inputs, return values, or stale external data feeds.
3. **Execution Ordering & Invariant Breakage**: Invariants momentarily violated during external calls or intermediary calculations.
4. **Economic & MEV Incentives**: Opportunities for adversaries to extract value through arbitrage, flash loans, or sandwiching.

---

## Step-by-Step Audit Checklist

When reviewing contracts in this domain, follow this rigorous step-by-step verification pipeline:

### 1. Architectural & Entry Point Reconnaissance
- Map all external and public entry points that interact with the vulnerable component.
- Verify access control modifiers (`onlyOwner`, `onlyRole`, custom guards) and ensure roles are initialized in constructor/initializer.
- Trace all token balance state changes and external contract interactions.

### 2. Deep Invariant & Boundary Verification
- **Input Validation**: Check zero-address (`address(0)`), zero-amount, array length parity, and upper/lower bounds.
- **State Transition Sequencing**: Verify Checks-Effects-Interactions (CEI) order. All storage updates must precede external invocations.
- **External Return Handling**: Check return values for external ERC-20 transfers, oracle queries, and delegatecalls.
- **Failure Paths & DoS Hazards**: Ensure reverts in third-party calls do not lock protocol funds or halt liquidation/withdrawal queues.

### 3. Proof of Concept (PoC) Development Guide
When drafting a reproducible exploit in Foundry (`forge test`):
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";

contract AuditPoCTest is Test {
    // Setup target protocol and mock dependencies
    function setUp() public {
        // Fork mainnet or deploy local environment
    }

    function test_exploit_vector() public {
        // 1. Establish initial attacker balance
        // 2. Trigger vulnerability
        // 3. Assert invariant violation or balance drain
    }
}
```

---

## Empirical Case Studies from Zaevlad/audit-findings-dataset

The following real-world contest findings illustrate the root causes and remediation patterns:

### Case Study: The incorrect accounting of protocol fee will
- **Severity**: High (Dataset Weight: 1.00, ID: #1719)
- **Vulnerability Mechanism**:
The incorrect accounting of protocol fee will cause double charging fee and wrong distribution of earnings for variable users.
The calculation for a variable user's earnings, when they withdraw where isStarted() and !isEnded()
41c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L520-L545
```solidity
uint256 lidoStETHBalance = stakingBalance();
// staking earnings have accumulated on Lido
uint256 currentStakes = stakingShares();
(uint256 currentState, uint256 ethAmountOwed) =
calculateVariableWithdrawState(
    lidoStETHBalance.mulDiv(currentStakes +
    variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(lidoStETHBalance, currentStakes)
);
if (ethAmountOwed >= minStETHWithdrawalAmount()) {
    // estimate protocol fee and update total - will actually be applied on withdraw finalization
    uint256 protocolFee = ethAmountOwed.mulDiv(protocolFeeBps, 10000);
    totalProtocolFee += protocolFee;
    uint256 stakesAmountOwed = lido.getSharesByPooledEth(ethAmountOwed);
    withdrawnStakingEarnings += ethAmountOwed - protocolFee;
    withdrawnStakingEarningsInStakes += stakesAmountOwed;
    variableToWithdrawnStakingEarnings[msg.sender] += ethAmountOwed - protocolFee;
 

#### Proof of Concept / Vulnerable Pattern
```solidity
Add a setter in LidoVault.sol to set lido and lidoWithdrawalQueue to the mock version for easier debugging
41c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L1003-L1007
```solidity
/// @notice Lido contract
- ILido public constant lido = ILido(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84);
+ ILido public lido = ILido(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84);
/// @notice Lido withdrawal queue contract
- ILidoWithdrawalQueueERC721 public constant lidoWithdrawalQueue = ILidoWithdrawalQueueERC721(0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1);
+ ILidoWithdrawalQueueERC721 public lidoWithdrawalQueue = ILidoWithdrawalQueueERC721(0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1);
+ function setLidoInfo(address _lido, address _withdrawalQueue) public {
+   lido = ILido(_lido);
+   lidoWithdrawalQueue = ILidoWithdrawalQueueERC721(_withdrawalQueue);
+ }
```
Run command: forge test --match-path test/PoC.t.sol -vv
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.18;
import { VaultFactory } from "contracts/VaultFactory.sol";
import { LidoVault } from "contracts/LidoVault.sol";
import { Test, console } from "forge-std/Test.sol";
contract MockLido {
    uint256 public rate;
    
```

- **Remediation Recommendation**:
```solidity
stakesAmountOwed at LidoVault.sol:534 should exclude the protocolFee
uint256 protocolFee = ethAmountOwed.mulDiv(protocolFeeBps, 10000);
totalProtocolFee += protocolFee;
- uint256 stakesAmountOwed = lido.getSharesByPooledEth(ethAmountOwed);
+ uint256 stakesAmountOwed = lido.getSharesByPooledEth(ethAmountOwed - protocolFee);
```
```solidity
totalEarnings at LidoVault.sol:775 should exclude the deduction of totalProtocolFee
- uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings;
+ uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) + vaultEndedStakingEarnings;
```

---
### Case Study: Using timeToTransmute instead of transmutationTime affects already created redemptions
- **Severity**: High (Dataset Weight: 1.00, ID: #4222)
- **Vulnerability Mechanism**:
```solidity
uint256 blocksLeft = position.maturationBlock > block.number ? position.maturationBlock - block.number : 0;
uint256 amountNottransmuted = blocksLeft > 0 ? position.amount * blocksLeft / timeToTransmute : 0;
uint256 amountTransmuted = position.amount - amountNottransmuted;
```
The global variable timeToTransmute determines the duration that new redemptions must wait before completely transmuting. According to the team, the Transmuter is designed so that the total time required for maturity is established when a redemption is created.
By using the variable timeToTransmute here, the maturation time for an existing position is affected when the global variable is updated, which is not intended.
Furthermore, as the correct maturation times are used for updating and querying the staking graph, the earmarking accounting would become incorrect, and either too little or too much would be earmarked as timeToTransmute changed.

#### Proof of Concept / Vulnerable Pattern
```solidity
timeToTransmute is set to 200,000 blocks, roughly 28 days.
• User A creates a redemption for 10,000 alUSD at block T. The maturation block is set to T + 200,000.
• A week and 50,000 blocks later, due to increasing yield and an unstable alUSD peg, timeToTransmute is halved to 100,000 blocks.
• User A decides to exit because they need liquidity. Their position has matured for a week, so their remaining position should be 7,500 alUSD, and they should receive the rest (valued at 2,500 USD) in yield tokens.
• Due to the above calculation, however, their remaining position will be calculated to:
```solidity
uint256 blocksLeft = T + 200_000 > T + 50_000 ? T + 200_000 - (T + 50_000) : 0;
// 150_000
uint256 amountNottransmuted = 150_000 > 0 ? 10_000 * 150_000 / 100_000 : 0;
// 15_000
uint256 amountTransmuted = 10_000 - 15_000;
// Underflow!
```
• Because timeToTransmute halved, User A's remaining debt immediately doubled to 15,000, which is higher than their original deposit. As a result of transmutation times in general decreasing, theirs, and all existing positions, had their maturation time increased. User A would now have to wait another week until they even start transmuting.
• Another
```

- **Remediation Recommendation**:
Further down in the code, there is the following line:
```solidity
uint256 transmutationTime = position.maturationBlock - position.startBlock;
```
Move that line up to before the problematic line and replace timeToTransmute in the denominator with transmutationTime.

---
### Case Study: Inverted Merkle Proof Veriﬁcation in claimLogic Submitted by Luck, also found by MukulKolpe, j0xbear, Shubham, thisvishalsingh, sohrabhind, shealtielanz, 0xTheBlackPanther, VAD37, chrissavov, ilyadruzh, pineneedles, 0x37, magicCentaur, SpDream, 0xAura, SpDream, BengalCatBalu, krishnambstu, limesss, Agontuk1, JesJupyter, Timepunk, aksoy, Aslanbek Aibimov, mmvds, ZoA, merulz99, TamayoNft, chainsentry and newspacexyz
- **Severity**: High (Dataset Weight: 1.00, ID: #4578)
- **Vulnerability Mechanism**:
When verifying a Merkle proof, the contract leverages OpenZeppelin's MerkleProof.verify, which returns true if and only if a leaf can be proved to be part of the Merkle tree defined by root.
According to OpenZeppelin's MerkleProof:
```solidity
/**
 * @dev Returns true if a leaf can be proved to be a part of a Merkle tree
 * defined by root. For this, a proof must be provided, containing
 * sibling hashes on the branch from the leaf to the root of the tree. Each
 * pair of leaves and each pair of pre-images are assumed to be sorted.
 *
 * This version handles proofs in memory with a custom hashing function.
 */
function verify(
    bytes32[] memory proof,
    bytes32 root,
    bytes32 leaf,
    function(bytes32, bytes32) view returns (bytes32) hasher
) internal view returns (bool) {
    return processProof(proof, leaf, hasher) == root;
}
```
In the vulnerable claimLogic(...) function, however, the code uses require(!_verify(...)), thus negating the result of MerkleProof.verify. Speciﬁcally:
```solidity
function claimLogic(
    EpochDistributorStorage storage $e,
    DefiAppHomeCenterStorage storage $,
    uint256 epoch,
    MerkleUserDistroInput memory distro,
    bytes32[] calldata

#### Proof of Concept / Vulnerable Pattern
```solidity
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "forge-std/Test.sol";

/**
 * @notice A minimal PoC demonstrating the inverted Merkle-proof check:
 * using `require(! _verify(...))` instead of `require(_verify(...))`.
 * This version ensures function arguments use `memory` so that we
 * can pass memory variables from the same test contract.
 */
contract EpochDistributorPoC is Test {
    /// @dev Simulated mapping: whether a user has claimed in a given epoch.
    mapping(uint256 => mapping(address => bool)) public isClaimed;

    /// @dev Simulated distribution Merkle root for each epoch.
    mapping(uint256 => bytes32) public distributionMerkleRoots;

    /**
     * @dev A simplified struct for demonstration. In real code, you'd have more fields
     * or different hashing logic for forming the leaf from (tokens, user, etc.).
     */
    struct MerkleUserDistroInput {
        uint256 tokens;
        address userId;
    }

    /**
     * @dev A mock verify function. In real usage, you'd do:
     * bool isValid = MerkleProof.verify(proof, root, leaf);
     * Here, `pretendValid` is just a bool to simulate "true = valid proof".
     */
    function _verify(
```

- **Remediation Recommendation**:
Replace the inverted check:
```solidity
require(
    !_verify(distroProof, $e.distributionMerkleRoots[epoch], getLeaveUserDistroTree(distro)),
    EpochDistributor_invalidDistroProof()
);
```
with the correct logic:
```solidity
require(
    _verify(distroProof, $e.distributionMerkleRoots[epoch], getLeaveUserDistroTree(distro)),
    EpochDistributor_invalidDistroProof()
);
```
By removing the negation, valid proofs (_verify == true) will pass, invalid proofs (_verify == false) will revert, restoring the intended Merkle proof security.

---
### Case Study: It is impossible to claim rewards in zlrewardscontroller.sol Submitted by osmanozdemir1, also found by Federico Bianucci, Sujith Somraaj, jovi.eth, 0xhashiman, tnch, bin2chen, elhaj, Chinmay Farkya, pauleth, Naveen Kumar J - 1nc0gn170, waﬄemakr, 0xarno and shaka
- **Severity**: High (Dataset Weight: 1.00, ID: #4994)
- **Vulnerability Mechanism**:
```solidity
function claim(
address _user,
address[] memory _tokens
) public whenNotPaused {
// SKIPPED FOR BREVITY
_vestTokens(_user, pending);
}
```
The claim function performs some checks and actions (calculates reward debt and user pending reward etc), and then calls the _vestTokens function.
```solidity
function _vestTokens(address _user, uint256 _amount) internal {
if (_amount == 0) revert NothingToVest();
streamedVesting.createVestFor(_user, _amount); //@audit-issue streamedVesting contract will try to burn vestedTokens from this contract. This contract never approved the StreamedVesting contract
}
```
The _vestTokens function then will call the streamedVesting.createVestFor function, which uses the msg.sender as the from address, and the msg.sender is the ZLRewardsController contract.
```solidity
function createVestFor(address to, uint256 amount) external whenNotPaused {
_createVest(msg.sender, to, amount); //@audit msg.sender is ZLRewardsController contract
}
```
```solidity
function _createVest(
address from,
address to,
uint256 amount
) internal whenNotPaused {
vestedToken.burnFrom(from, amount); //@audit-issue "from" is ZLRewardsController contract. That contract is nev

#### Proof of Concept / Vulnerable Pattern
```solidity
Before running the test we need to do a slight change in the protocol's fixture. We are going to change the pool configurator to make testing easier. Perform the change below in the test/fixtures/core.ts test file:
```solidity
await zLRewardsController.initialize(
    owner.address, // address _poolConfigurator,
    vesting.target, // IStreamedVesting _streamedVesting,
    locker.target, // IZeroLocker _locker,
    1000, // uint256 _rewardsPerSecond,
    token.target, // address _rdntToken,
    0 // uint256 _endingTimeCadence
);
```
After changing these lines, do the following steps:
• Create an empty file in the test folder and name it something.ts.
• Copy and paste the snippet below into the newly created file.
• Run it with npx hardhat test --grep "Always reverts when claiming".
```javascript
import { expect } from "chai";
import { ethers } from "hardhat";
import {
loadFixture,
time,
} from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { e18, deployCore as fixture } from "./fixtures/core";
describe("RewardsController", () => {
it("Always reverts when claiming", async function () {
const {
token: rewardToken,
vestedToken,
owner,
zLRewardsController,
otherAccount: use
```

- **Remediation Recommendation**:
Approve the streamedVesting contract as spender during initialize.
```solidity
// Inside the initialize
vestedToken.approve(address(steamedVesting), type(uint256).max)
```

---
### Case Study: User’s Staked ETH Can Be Stuck in the Protocol Through Two Cases
- **Severity**: High (Dataset Weight: 1.00, ID: #8525)
- **Vulnerability Mechanism**:
The deposit() function in the Portal.sol contract allows users to stake their ETH into any staking pool that is created. If the user wants to unstake his ETH he must make a withdrawal request to the WithdrawalContract.sol contract of the given staking pool by calling the enqueue() function. Note: When a staking pool is created, a separate contract is deployed for that staking pool called WithdrawalContract.sol. The impact is primarily financial, as the current implementation for enqueue() function in WithdrawalContract.sol compels users to initiate a withdrawal with a minimum of 0.05 ETH.

In contrast Portal.sol, the deposit() function lacks any restrictions on the amount of ETH that can be added to a staking pool. The following two scenarios show two Proof of Concepts of how user’s funds can be stuck in the protocol.

#### Proof of Concept / Vulnerable Pattern
```solidity
Scenario 1  
```solidity
it("Alice's funds locked - first case", async function () {
// 1. CreatePool - The poolOwner creates a public pool
// 2. CreateOperator - The operatorOwner initiates the operator
// 3. Alice wants to stake 0.04 ETH in this public poll and
// she calls deposit() function , accordingly she will receive 0.04 gETH.
const aliceFirstDepositAmount = new BN(String(1e18)).muln(0.04); // 0.04 ETH
await this.Portal.deposit(publicPoolId, 0, [], 0, MAX_UINT256, aliceStaker, {
from: aliceStaker,
value: aliceFirstDepositAmount,
});
// 4. PoolOwner sees Alice's successful transaction and decides to change the visibility of the pool to private.
// this means that if a pool is not public , only the controller and the whitelisted addresses can deposit/stake.
await this.Portal.setPoolVisibility(publicPoolId, true, {
from: poolOwner,
});
// 5. Expect true to be executed as the pool is private.
expect(await this.Portal.isPrivatePool(publicPoolId)).to.be.equal(true);
// 6. Alice decides to unstake her 0.04 ETH from the "public pool" and
// she calls enqueue() function to queue a withdrawal request into the queue.
// Expect revert to be executed as the minimum size of the withdraw
```

- **Remediation Recommendation**:
To address this vulnerability, it is crucial to implement a check in the deposit() function for minimum deposit limit of 0.05 ETH or remove the check for the minimum size of the withdrawal request for enqueue() function.

Solution one:  
File: contracts/Portal/modules/StakeModule/libs/StakeModuleLib.sol#L1023  
```solidity
uint256 private constant _MIN_DEPOSIT_SIZE = 0.05 ether;
function deposit(
PooledStaking storage self,
DSML.IsolatedStorage storage DATASTORE,
uint256 poolId,
uint256 mingETH,
uint256 deadline,
address receiver
) external returns (uint256 boughtgETH, uint256 mintedgETH) {
_authenticate(DATASTORE, poolId, false, false, [false, true]);
require(msg.value >= _MIN_DEPOSIT_SIZE, "SML:min 0.05 ETH");
.
.
}
```

Solution two:  
File: contracts/Portal/modules/WithdrawalModule/lib

---
### Case Study: Setting The Configuration with Arrays of Longer Than 1 Causes Incorrect Config
- **Severity**: High (Dataset Weight: 1.00, ID: #8617)
- **Vulnerability Mechanism**:
In the `WheelOfGuantune` contract the expectation is that the configuration will be set using the `configureWheel()` function. The input parameters expect an array of values as below:
```solidity
function configureWheel(
    uint256[] calldata segmentsPerReward,
    RewardType[] calldata rewardTypes,
    bytes[] calldata rewardValues,
    uint256 epochDuration
)
external
onlyOwner
```
When the array length of the input is longer than 1 then the loop that runs causes the storage variables to have a new item pushed to the storage variables to accommodate for the new configuration values as below:

```solidity
// iterate over the params to configure the wheel
for (uint256 i; i < expectedParamsLength; i++) {
    // first, get the values of the current iteration
    // note: the reward id is the configuration index + 1
    uint256 rewardId = i + 1;
    uint256 segments = segmentsPerReward[i];
    RewardType rewardType = rewardTypes[i];
    bytes memory rewardValue = rewardValues[i];
    // create new UintToUintMap instances for the new rewards config, resetting the previously configured values
    $.config.rewardTypeOfRewardId.push();
    $.config.rewardIdOfSegment.push();
    $.config.

#### Proof of Concept / Vulnerable Pattern
```solidity
```solidity
function test_OverwriteConfig() external {
    setBiggerConfig();
    WheelOfGuantune.Reward[] memory rewards = MockWheelOfGuantune(wheelproxy).getWheelRewards();
    for(uint256 i; i < rewards.length; i++){
        console.log("in loop");
        WheelOfGuantune.Reward memory tmpReward = rewards[i];
        console.log("[%d] rewardType is: %d", i, uint256(tmpReward.rewardType));
        console.log("[%d] value is: ", i);
        console.logBytes(tmpReward.value);
    }
    setBiggerConfig();
    WheelOfGuantune.Reward[] memory rewardsnew = MockWheelOfGuantune(wheelproxy).getWheelRewards();
    for(uint256 i; i < rewardsnew.length; i++){
        WheelOfGuantune.Reward memory tmpReward = rewardsnew[i];
        console.log("[%d] rewardType is: %d", i, uint256(tmpReward.rewardType));
        console.log("[%d] value is: ", i);
        console.logBytes(tmpReward.value);
    }
    setBiggerConfig();
    rewardsnew = MockWheelOfGuantune(wheelproxy).getWheelRewards();
    for(uint256 i; i < rewardsnew.length; i++){
        WheelOfGuantune.Reward memory tmpReward = rewardsnew[i];
        console.log("[%d] rewardType is: %d", i, uint256(tmpReward.rewardType));
        console.log
```

- **Remediation Recommendation**:
Move the code that pushes new values outside the loop to only run once per call to configureWheel():
```solidity
function configureWheel(
    uint256[] calldata probabilityPerReward,
    uint256[] calldata segmentsPerReward,
    RewardType[] calldata rewardTypes,
    bytes[] calldata rewardValues,
    uint256 epochDuration
)
external
onlyOwner
{
    // code

    // prepare the new total segments value
    uint256 totalSegments;
    // create new UintToUintMap instances for the new rewards config, resetting the previously configured values
    $.config.rewardTypeOfRewardId.push();
    $.config.rewardIdOfSegment.push();
    $.config.tokenIdOfRewardId.push();
    $.config.extraSpinsOfRewardId.push();
    $.config.gPointsOfRewardId.push();
    // iterate over the params to configure the wheel



---

## Remediation & Hardening Principles

- **Fail-Safe Defaults**: Always favor reverting rather than proceeding with invalid or unexpected state.
- **Defensive Type Safety**: Use OpenZeppelin's vetted libraries (`SafeERC20`, `ReentrancyGuardUpgradeable`, `SafeCast`, `Math`).
- **Two-Step Critical Actions**: Enforce commit-reveal or timelock mechanisms for governance and high-impact parameter changes.
- **Comprehensive Testing Matrix**: Pair unit tests with property-based invariant testing (Foundry `invariant` or Echidna) and mutation testing.

