#41215 [SC-Critical] StakeV2: Inconsistencies in totalSupply computation, can lead to protocol insolvency

Submitted on Mar 12th 2025 at 14:53:27 UTC by @max10afternoon for Audit Comp | Yeet

  • Report ID: #41215

  • Report Type: Smart Contract

  • Report severity: Critical

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

  • Impacts:

    • Protocol insolvency

Description

Brief/Intro

Unstaking will decrease the totalSupply, by the amount being unstaken, but won't transfer the tokens until, the unstake process gets completed. This will effect the computation of accumulatedDeptRewardsYeet. Meaning that the unstaken amount will be distributable as reward, making the protocol insolvent.

Vulnerability Details

The executeRewardDistributionYeet allows to redistribute Yeet tokens accumulated in the contract as reward. The amount that can be distributed gets computed by the accumulatedDeptRewardsYeet function:

   /// @notice The function used to distribute excess rewards to the vault.
    function executeRewardDistributionYeet(
        IZapper.SingleTokenSwap calldata swap,
        IZapper.KodiakVaultStakingParams calldata stakingParams,
        IZapper.VaultDepositParams calldata vaultParams
    ) external onlyManager nonReentrant {
        uint256 accRevToken0 = accumulatedDeptRewardsYeet();
        require(accRevToken0 > 0, "No rewards to distribute");
        require(swap.inputAmount <= accRevToken0, "Insufficient rewards to distribute");

        stakingToken.approve(address(zapper), accRevToken0);
        IERC20 token0 = IKodiakVaultV1(stakingParams.kodiakVault).token0();
        IERC20 token1 = IKodiakVaultV1(stakingParams.kodiakVault).token1();

        uint256 vaultSharesMinted;
        require(
            address(token0) == address(stakingToken) || address(token1) == address(stakingToken),
            "Neither token0 nor token1 match staking token"
        );

        if (address(token0) == address(stakingToken)) {
            (, vaultSharesMinted) = zapper.zapInToken0(swap, stakingParams, vaultParams);
        } else {
            (, vaultSharesMinted) = zapper.zapInToken1(swap, stakingParams, vaultParams);
        }

        _handleVaultShares(vaultSharesMinted);
        emit RewardsDistributedToken0(accRevToken0, rewardIndex);
    }

The unstaking process is divided in two parts startUnstake and either unstake or rageQuit. During startUnstake, the balance of the user, gets burnt and the totalSupply gets decreased:

Than, possibly after a vesting period, the tokens gets transferred to the user:

This means that after startUnstake gets called, the accumulatedDeptRewardsYeet will account for the unstaked amount as reward that can be distributed. Allowing the tokens to be sent to the underlying vault, and making the the contract insolvent, as it will not have enough liquidity to pay for the unstake finalization, while also distributing the rewards among other users.

Note on access control

The executeRewardDistributionYeet function, has a onlyManager modifier, that said, this issue doesn't arise from a wreckless or malicious behavior by the manager, but will also be caused by a regular usage of the function as the accumulatedDeptRewardsYeet function will advertise the wrong value as being available for distribution. In other words, the issue depends on a logical issue in the smart contract and not on an administrative account breaking any trust assumptions.

Impact Details

Insolvency: The smart contract will advertise and use, more funds that what is has, leading to insolvency.

Proof of Concept

To run the coded PoC, copy the following file, in the ./test folder, of the contest's github repository:

Was this helpful?