The AlchemistV3 contract fails to decrement the internal _mytSharesDeposited variable when yield tokens are transferred out during liquidation operations (_doLiquidation and _forceRepay). This accounting error causes _mytSharesDeposited to become inflated relative to the actual MYT token balance held by the contract. As a consequence, the deposit cap check becomes overly restrictive (preventing legitimate deposits even when capacity exists), the Total Value Locked (TVL) calculation via _getTotalUnderlyingValue() becomes overstated, and the bad debt ratio used by the Transmuter for redemption payouts is understated, potentially allowing users to extract more value than entitled during system insolvency
Vulnerability Details
The _mytSharesDeposited variable is designed to track the total yield tokens deposited into CDPs, as indicated by its comment
/// This is used to differentiate between tokens deposited into a CDP and balance of the contract
uint256 private _mytSharesDeposited;
Throughout the codebase, _mytSharesDeposited is correctly updated in most token transfer operations:
Correct implementations:
deposit(): Increments _mytSharesDeposited when tokens enter
withdraw(): Decrements _mytSharesDeposited when tokens leave
burn(): Decrements _mytSharesDeposited for protocol fee deduction
repay(): Decrements _mytSharesDeposited for protocol fee deduction
redeem(): Decrements _mytSharesDeposited for all outbound transfers
However, liquidation flows are missing these decrements:
The inflated _mytSharesDeposited impacts three critical area
Deposit Cap Check (AlchemistV3.sol)
TVL Calculation (AlchemistV3.sol)
Bad Debt Ratio in Transmuter (Transmuter.sol)
Impact Details
Deposit Cap Bypass Prevention (DoS)
After liquidations occur, _mytSharesDeposited remains inflated while the actual MYT balance decreases. This may causes the deposit cap check to incorrectly reject valid deposits
Overstated TVL Calculations
The _getTotalUnderlyingValue() function directly uses the inflated _mytSharesDeposited
Understated Bad Debt Ratio in Transmuter The Transmuter uses getTotalUnderlyingValue() (which relies on _mytSharesDeposited) to calculate the bad debt ratio for scaling redemption payouts:
add this below pocs to the AlchemistV3.t.sol test file
function testLiquidation_Breaking_smartkelvin_mytBug() external {
vm.startPrank(someWhale);
IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale);
vm.stopPrank();
// Set tight deposit cap
address actualAdmin = alchemist.admin();
vm.prank(actualAdmin);
alchemist.setDepositCap(depositAmount * 2);
// Fill up to exactly the cap
vm.startPrank(address(0xbeef));
SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18);
alchemist.deposit(depositAmount, address(0xbeef), 0);
uint256 tokenId1 = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT));
alchemist.mint(tokenId1, alchemist.totalValue(tokenId1) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef));
vm.stopPrank();
vm.startPrank(yetAnotherExternalUser);
SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2);
alchemist.deposit(depositAmount, yetAnotherExternalUser, 0);
vm.stopPrank();
// Now at cap - further deposits should fail
vm.startPrank(address(0xdead));
SafeERC20.safeApprove(address(vault), address(alchemist), 1e18);
vm.expectRevert(); // Should fail - at cap
alchemist.deposit(1e18, address(0xdead), 0);
vm.stopPrank();
// Liquidate to free up actual space
uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply();
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply);
uint256 modifiedVaultSupply = (initialVaultSupply * 590 / 10_000) + initialVaultSupply;
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply);
uint256 balanceBefore = IERC20(address(vault)).balanceOf(address(alchemist));
vm.startPrank(externalUser);
(uint256 amountLiquidated, uint256 feeInYield,) = alchemist.liquidate(tokenId1);
vm.stopPrank();
uint256 balanceAfter = IERC20(address(vault)).balanceOf(address(alchemist));
uint256 tokensFreed = balanceBefore - balanceAfter;
console.log("Tokens freed from contract:", tokensFreed);
console.log("Amount to transmuter:", amountLiquidated - feeInYield);
// Even though actual tokens left the contract,
// we still cannot deposit because _mytSharesDeposited wasn't decremented
vm.startPrank(address(0xdead));
uint256 attemptDeposit = tokensFreed / 2; // Try to use half the freed space
SafeERC20.safeApprove(address(vault), address(alchemist), attemptDeposit);
// This SHOULD succeed since actual tokens left, but WILL FAIL due to bug
vm.expectRevert(); // Bug: deposit cap check uses stale _mytSharesDeposited
alchemist.deposit(attemptDeposit, address(0xdead), 0);
console.log("CONFIRMED: Cannot deposit despite actual space available");
console.log("Root cause: _mytSharesDeposited not decremented in _doLiquidation");
vm.stopPrank();
}