The StakeV2 contract has a flaw in the startUnstake function that can lead to a permanent loss of user funds. When users initiate an unstake request, the contract’s totalSupply decreases, but its stakingToken balance remains unchanged. This discrepancy causes the contract’s accumulatedDeptRewardsYeet to increase, creating a potential risk where a manager can use these funds before users can withdraw them.
Vulnerability Details
When a user initiates an unstake, they must first call the StakeV2::startUnstake function. However, this function only decreases totalSupply without affecting the contract’s stakingToken balance.
StakeV2::startUnstake function:
function startUnstake(uint256 unStakeAmount) external {
...
// @note totalSupply decreases, but the YEET balance of this contract does not change
// @note As a consequence, accumulatedDeptRewardsYeet increases (balance - totalSupply)
// @audit Users lose funds if the manager calls executeRewardDistributionYeet.
totalSupply -= unStakeAmount;
...
}
Because of this, the StakeV2::accumulatedDeptRewardsYeet() will increase by the same amount the user intends to unstake. The unstaked amount remains locked in the contract, users are allowed to withdraw all after 10 days.
However, what if, before the lock period ends, the manager calls StakeV2::executeRewardDistributionYeet, utilizing all accumulatedDeptRewardsYeet() funds. Then the unstake amount is zapped into the Zapper contract, permanently removing it from StakeV2. When the user attempts to withdraw their unstaked tokens after the waiting period, the contract no longer has the funds, leading to irrecoverable user losses.
Impact Details
The StakeV2 contract will permanently lose funds allocated for unstaking.
Some users may be unable to withdraw their full staked amount due to missing funds.
Proof of Concept
Proof of Concept
Put the following code into the test/StakeV2.test.sol file and run forge test --mt test_unableToUnstake.
contract StakeV2_Unstake is Test {
MockERC20 public token;
MockWETH public wbera;
SimpleZapperMock mockZapper;
KodiakVaultV1 kodiakVault;
StakeV2 stakeV2;
address owner = makeAddr("owner");
address manager = makeAddr("manager");
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
function setUp() public virtual {
token = new MockERC20("MockERC20", "MockERC20", 18);
wbera = new MockWETH();
kodiakVault = new KodiakVaultV1(token, wbera);
mockZapper = new SimpleZapperMock(kodiakVault.token0(), kodiakVault.token1());
stakeV2 = new StakeV2(token, mockZapper, owner, manager, IWETH(wbera));
}
function test_unableToUnstake() public {
token.mint(user1, 100 ether);
token.mint(user2, 50 ether);
// Stake
vm.startPrank(user1);
token.approve(address(stakeV2), 100 ether);
stakeV2.stake(100 ether);
vm.stopPrank();
vm.startPrank(user2);
token.approve(address(stakeV2), 50 ether);
stakeV2.stake(50 ether);
vm.stopPrank();
assertEq(token.balanceOf(address(stakeV2)), 150 ether);
assertEq(stakeV2.accumulatedDeptRewardsYeet(), 0);
// User1 starts unstake
vm.startPrank(user1);
stakeV2.startUnstake(100 ether);
vm.stopPrank();
assertEq(token.balanceOf(address(stakeV2)), 150 ether);
assertEq(stakeV2.accumulatedDeptRewardsYeet(), 100 ether);
// Distribute YEET
vm.startPrank(manager);
mockZapper.setReturnValues(1, 1); // does not matter
stakeV2.executeRewardDistributionYeet(
IZapper.SingleTokenSwap(stakeV2.accumulatedDeptRewardsYeet(), 0, 0, address(0), ""),
IZapper.KodiakVaultStakingParams(address(kodiakVault), 0, 0, 0, 0, 0, address(0)),
IZapper.VaultDepositParams(address(0), address(0), 0)
);
vm.stopPrank();
assertEq(token.balanceOf(address(stakeV2)), 50 ether);
// Unable to unstake
vm.warp(block.timestamp + 11 days);
vm.startPrank(user1);
vm.expectRevert();
stakeV2.unstake(0);
vm.stopPrank();
}
}