#41911 [SC-Critical] Unstake amount can be zapped before user withdrawal

Submitted on Mar 19th 2025 at 10:06:16 UTC by @Ragnarok for Audit Comp | Yeet

  • Report ID: #41911

  • Report Type: Smart Contract

  • Report severity: Critical

  • Target: https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/StakeV2.sol

  • Impacts:

    • Permanent freezing of funds

Description

Brief/Intro

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

Was this helpful?