Smart contract unable to operate due to lack of token funds
Protocol insolvency
Description
Brief/Intro
When Alchemist V3 sends MYT out of the contract during force-repay and liquidation flows, it does not decrement _mytSharesDeposited unlike the redeem function. Because getTotalUnderlyingValue() is computed from _mytSharesDeposited, the system overstates TVL after such transfers. This misreporting weakens liquidation thresholds, can under-liquidate risky positions.
Vulnerability Details
Intended behavior (consistent path: redeem): When MYT leaves the Alchemist during redemptions, _mytSharesDeposited is decremented, keeping TVL aligned with the actual share balance.
Therefore, after a force-repay or liquidation, the contract’s actual MYT balance decreases but TVL (based on _mytSharesDeposited) remains unchanged, creating an accounting drift.
Impact Details
Misreported TVL inflates the system’s perceived collateralization and feeds directly into liquidation math:
Liquidation computes a “global collateralization” input using _getTotalUnderlyingValue(); overstated TVL makes the system appear healthier than it is, reducing liquidation severity or preventing needed liquidations.
Under-liquidation of risky positions increases the chance that price moves or further redemptions push the system into bad debt.
Paste the following test in AlchemistV3.t.sol and run the command forge test --match-test test_MytSharesDeposited_NotDecremented_OnForceRepayOrLiquidation
if (account.collateralBalance > protocolFeeTotal) {
account.collateralBalance -= protocolFeeTotal;
// Transfer the protocol fee to the protocol fee receiver
TokenUtils.safeTransfer(myt, protocolFeeReceiver, protocolFeeTotal);
}
if (creditToYield > 0) {
// Transfer the repaid tokens from the account to the transmuter.
TokenUtils.safeTransfer(myt, address(transmuter), creditToYield);
}
function test_MytSharesDeposited_NotDecremented_OnForceRepayOrLiquidation() external {
// 1) User deposits MYT (vault shares) and mints to the max so CR == minimumCollateralization
uint256 deposit = 1_000e18;
vm.startPrank(address(0xbeef));
SafeERC20.safeApprove(address(vault), address(alchemist), deposit);
alchemist.deposit(deposit, address(0xbeef), 0);
uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT));
// Mint to the maximum borrowable so initial ratio == lower bound threshold
uint256 maxBorrow = alchemist.getMaxBorrowable(tokenId);
// Ensure we are allowed to mint
alchemist.mint(tokenId, maxBorrow, address(0xbeef));
vm.stopPrank();
// Sanity: system state matches MYT balance and TVL
uint256 balBefore = IERC20(address(vault)).balanceOf(address(alchemist));
uint256 tvlBefore = alchemist.getTotalUnderlyingValue();
assertGt(balBefore, 0);
// Before any bug-triggering path, TVL should mirror MYT balance
assertEq(tvlBefore, alchemist.convertYieldTokensToUnderlying(balBefore));
// 2) Make the position undercollateralized by reducing share price (increase mock yield token supply)
// This ensures liquidation executes and transfers MYT out of Alchemist.
_manipulateYieldTokenPrice(1000); // ~10% supply increase -> share price down sufficiently
// 3) Trigger liquidation, which always performs _earmark() + _sync() and then:
// - repays earmarked via _forceRepay (sending MYT from Alchemist to Transmuter), and
// - if still under threshold, also performs _doLiquidation (more outbound MYT transfers).
(uint256 amountLiquidated,,) = alchemist.liquidate(tokenId);
// 4) After liquidation/force-repay, the Alchemist contract sent out MYT but did NOT decrement _mytSharesDeposited.
// Therefore, the actual MYT balance fell while getTotalUnderlyingValue() (based on _mytSharesDeposited) did not.
uint256 balAfter = IERC20(address(vault)).balanceOf(address(alchemist));
uint256 tvlAfter = alchemist.getTotalUnderlyingValue();
// We must have transferred some MYT out via force-repay and/or liquidation.
// If earmark was tiny, amountLiquidated can be zero yet force-repay still moved MYT; we assert on balance delta.
assertLt(balAfter, balBefore, "Alchemist MYT balance should decrease after repay/liquidation");
// Strict proof of bug: TVL (derived from _mytSharesDeposited) should now exceed what the contract actually holds.
uint256 underlyingFromActualBal = alchemist.convertYieldTokensToUnderlying(balAfter);
assertGt(tvlAfter, underlyingFromActualBal, "TVL based on _mytSharesDeposited should be greater than underlying from actual balance");
}