The accumulatedDeptRewardsYeet function incorrectly calculates the accumulated rewards by not accounting for unstaked tokens that are still in the contract. This discrepancy arises because totalSupply is reduced when users initiate unstaking, but the tokens remain in the contract until the vesting period ends. As a result, the function may return an inflated reward amount, leading to excessive distribution in executeRewardDistributionYeet, which could ultimately deplete user funds and prevent some users from unstaking their tokens.
Vulnerability Details
The vulnerability lies in the accumulatedDeptRewardsYeet function. stakingToken.balanceOf(address(this)) returns the total balance of staking tokens held by the contract, while totalSupply represents the total amount of staked tokens. However, totalSupply is reduced immediately when a user initiates unstaking via the startUnstake function, but the tokens are not transferred out of the contract until the vesting period ends. This means that stakingToken.balanceOf(address(this)) will still include the unstaked tokens, leading to an overestimation of the accumulated rewards.
The contract could distribute more rewards than it actually has, leading to a depletion of funds. Users who unstake later may find that there are insufficient tokens in the contract to cover their unstaking requests.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "forge-std/console2.sol";
import "../src/StakeV2.sol";
import {MockERC20} from "./mocks/MockERC20.sol";
import {MockWETH} from "./mocks/MockWBERA.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "./mocks/SimpleZapperMock.sol";
contract KodiakVaultV1 {
IERC20 public token0;
IERC20 public token1;
constructor(IERC20 _token0, IERC20 _token1) {
token0 = _token0;
token1 = _token1;
}
}
abstract contract StakeV2_BaseTest {
StakeV2 public stakeV2;
MockERC20 public token;
MockWETH public wbera;
SimpleZapperMock public mockZapper;
function setUp() public virtual {
token = new MockERC20("MockERC20", "MockERC20", 18);
wbera = new MockWETH();
address owner = address(this);
address manager = address(this);
mockZapper = new SimpleZapperMock(token, wbera);
stakeV2 = new StakeV2(token, mockZapper, owner, manager, IWETH(wbera));
}
function test() public {}
}
contract StakeV2_HandleExcessDebt is Test {
MockERC20 public token;
MockWETH public wbera;
function setUp() public virtual {
token = new MockERC20("MockERC20", "MockERC20", 18);
wbera = new MockWETH();
}
// make sure we can handle excess yeet when its token0
function test_accumulatedDeptRewardsYeet() public {
address owner = address(this);
address manager = address(this);
KodiakVaultV1 kodiakVault = new KodiakVaultV1(token, wbera);
SimpleZapperMock mockZapper = new SimpleZapperMock(
kodiakVault.token0(),
kodiakVault.token1()
);
StakeV2 stakeV2 = new StakeV2(
token,
mockZapper,
owner,
manager,
IWETH(wbera)
);
token.mint(address(this), 100 ether);
token.approve(address(stakeV2), 50 ether);
stakeV2.stake(50 ether);
// simulate debt by adding excess token0
token.transfer(address(stakeV2), 50 ether);
//zapper
mockZapper.setReturnValues(1, 1); // does not matter
assertEq(50 ether, stakeV2.accumulatedDeptRewardsYeet());
//@audit user unstake some amounts
stakeV2.startUnstake(40 ether);
//@audit accumulatedDeptRewardsYeet is now higher than it should be includes unstaked tokens
assertEq(90 ether, stakeV2.accumulatedDeptRewardsYeet());
stakeV2.depositReward{value: 1 ether}();
assertEq(100 ether, token.balanceOf(address(stakeV2)));
//@audit manager distributes all accumulatedDeptRewardsYeet which also sends unstaked tokens
stakeV2.executeRewardDistributionYeet(
IZapper.SingleTokenSwap(90 ether, 0, 0, address(0), ""),
IZapper.KodiakVaultStakingParams(
address(kodiakVault),
0,
0,
0,
0,
0,
address(0)
),
IZapper.VaultDepositParams(address(0), address(0), 0)
);
//@audit there should be 10+40 tokens left for user but only 10 tokens left
assertEq(10 ether, token.balanceOf(address(stakeV2)));
assertEq(90 ether, token.balanceOf(address(mockZapper)));
vm.warp(block.timestamp + 20 days);
//@audit there is not enough stake tokens left
vm.expectRevert();
stakeV2.unstake(0);
}
}