Immunefi Audit Competitions
Alchemix V3

56791 sc high missing mytsharesdeposited decrements in token transfers

Submitted on Oct 20th 2025 at 17:47:43 UTC by @teoslaf1 for Audit Comp | Alchemix V3

Description

Summary

The _mytSharesDeposited state variable is not decremented when MYT tokens are transferred out of the AlchemistV3 contract during liquidations and force repayments. This causes the Total Value Locked (TVL) to be artificially inflated, leading to incorrect collateralization calculations, deposit cap issues, and bad debt ratio miscalculations.

Vulnerability Details

Root Cause

_mytSharesDeposited tracks the total MYT shares deposited in the contract and is used to calculate TVL:

// Line 1244 in AlchemistV3.sol
function _getTotalUnderlyingValue() internal view returns (uint256 totalUnderlyingValue) {
    uint256 yieldTokenTVLInUnderlying = convertYieldTokensToUnderlying(_mytSharesDeposited);
    totalUnderlyingValue = yieldTokenTVLInUnderlying;
}

However, when tokens are transferred out during liquidations and force repayments, _mytSharesDeposited is not decremented, causing it to diverge from the actual token balance.

Missing Decrements

1. In _doLiquidation() (Lines 880 & 884)

// Line 880 - Transfer to transmuter
TokenUtils.safeTransfer(myt, transmuter, amountLiquidated - feeInYield);
// missing ->  _mytSharesDeposited -= (amountLiquidated - feeInYield);

// Line 884 - Transfer fee to liquidator
if (feeInYield > 0 && account.collateralBalance >= feeInYield) {
    TokenUtils.safeTransfer(myt, msg.sender, feeInYield);
    // missing -> _mytSharesDeposited -= feeInYield;
}

2. In _forceRepay() (Lines 779 & 785)

// Line 779 - Protocol fee transfer
if (account.collateralBalance > protocolFeeTotal) {
    account.collateralBalance -= protocolFeeTotal;
    TokenUtils.safeTransfer(myt, protocolFeeReceiver, protocolFeeTotal);
    //  missing -> _mytSharesDeposited -= protocolFeeTotal;
}

// Line 785 - Transmuter transfer
if (creditToYield > 0) {
    TokenUtils.safeTransfer(myt, address(transmuter), creditToYield);
    //  missing -> _mytSharesDeposited -= creditToYield;
}

Impact

Inflated TVL Calculations

  • getTotalUnderlyingValue() returns inflated values
  • Affects all collateralization ratio calculations throughout the protocol
  • System appears healthier than reality

Add _mytSharesDeposited decrements after each token transfer:

Fix for _doLiquidation()

function _doLiquidation(uint256 accountId, uint256 collateralInUnderlying, uint256 repaidAmountInYield)
    internal
    returns (uint256 amountLiquidated, uint256 feeInYield, uint256 feeInUnderlying)
{
    // ... existing code ...

    // send liquidation amount - fee to transmuter
    TokenUtils.safeTransfer(myt, transmuter, amountLiquidated - feeInYield);
+   _mytSharesDeposited -= (amountLiquidated - feeInYield);

    // send base fee to liquidator if available
    if (feeInYield > 0 && account.collateralBalance >= feeInYield) {
        TokenUtils.safeTransfer(myt, msg.sender, feeInYield);
+       _mytSharesDeposited -= feeInYield;
    }

    // ... rest of function ...
}

Fix for _forceRepay()

function _forceRepay(uint256 accountId, uint256 credit) internal returns (uint256 creditToYield) {
    // ... existing code ...

    if (account.collateralBalance > protocolFeeTotal) {
        account.collateralBalance -= protocolFeeTotal;
        // Transfer the protocol fee to the protocol fee receiver
        TokenUtils.safeTransfer(myt, protocolFeeReceiver, protocolFeeTotal);
+       _mytSharesDeposited -= protocolFeeTotal;
    }

    if (creditToYield > 0) {
        // Transfer the repaid tokens from the account to the transmuter.
        TokenUtils.safeTransfer(myt, address(transmuter), creditToYield);
+       _mytSharesDeposited -= creditToYield;
    }

    return creditToYield;
}

Proof of Concept

Add this to AlchemistV3.t.sol

function testPOC_MytSharesDeposited_Not_Decremented_On_Liquidate() external {
    vm.startPrank(someWhale);
    IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale);
    vm.stopPrank();
    
    uint256 depositAmt = 200_000e18;
    
    // Create healthy position for global collateralization
    vm.startPrank(yetAnotherExternalUser);
    SafeERC20.safeApprove(address(vault), address(alchemist), depositAmt * 2);
    alchemist.deposit(depositAmt, yetAnotherExternalUser, 0);
    vm.stopPrank();
    
    // Create position to be liquidated
    vm.startPrank(address(0xbeef));
    SafeERC20.safeApprove(address(vault), address(alchemist), depositAmt);
    alchemist.deposit(depositAmt, address(0xbeef), 0);
    uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT));
    alchemist.mint(tokenId, alchemist.totalValue(tokenId) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef));
    vm.stopPrank();
    
    // Crash collateral value
    uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply();
    IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply);
    uint256 modifiedVaultSupply = (initialVaultSupply * 590 / 10_000) + initialVaultSupply;
    IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply);
    
    // State before liquidation
    uint256 actualBalanceBefore = vault.balanceOf(address(alchemist));
    uint256 transmuterBalanceBefore = vault.balanceOf(address(transmuterLogic));
    
    // Liquidate
    vm.prank(externalUser);
    alchemist.liquidate(tokenId);
    
    // State after liquidation
    uint256 tvlAfter = alchemist.getTotalUnderlyingValue();
    uint256 actualBalanceAfter = vault.balanceOf(address(alchemist));
    uint256 tokensToTransmuter = vault.balanceOf(address(transmuterLogic)) - transmuterBalanceBefore;
    uint256 tokensTransferredOut = actualBalanceBefore - actualBalanceAfter;
    
    assertGt(tokensTransferredOut, 0, "Tokens transferred out");
    assertGt(tokensToTransmuter, 0, "Tokens sent to transmuter");
    assertGt(tvlAfter, actualBalanceAfter, "BUG: TVL inflated vs actual balance");
    
    console.log("TVL:", tvlAfter);
    console.log("Actual Balance:", actualBalanceAfter);
    console.log("Inflation:", tvlAfter - actualBalanceAfter);
}