Contract fails to deliver promised returns, but doesn't lose value
Description
Bug Report: AlchemistV3 _mytSharesDeposited Not Reduced When Repaid Collateral Sent to Transmuter
Severity
HIGH
Summary
The AlchemistV3._forceRepay() function transfers repaid collateral (MYT tokens) to the transmuter but does not decrement _mytSharesDeposited. This creates a critical accounting mismatch where _mytSharesDeposited shows inflated values compared to actual token balances, incorrectly capping deposits and masking the true protocol state.
Vulnerable Code
Force Repay Transfers Collateral Without Accounting Update
File: src/AlchemistV3.sol
File: src/AlchemistV3.sol
Vulnerability Details
The Accounting Flow
When users deposit MYT:
When force liquidation repays debt:
Impact
Severity Justification
1. Deposit Cap Becomes inconsistent with the true system state
If depositCap = 1000e18
Real deposits = 800e18 (should allow 200e18 more)
But _mytSharesDeposited shows 950e18 due to untracked transfers out
Result: Only 50e18 deposits allowed instead of 200e18
Recommended Fix
Decrement _mytSharesDeposited When Sending to Transmuter
forge test --match-test testForceRepayAccountingBugSimple -vv
function testForceRepayAccountingBugSimple() external {
// Setup: Create a position and earmark debt through transmuter
vm.startPrank(someWhale);
IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale);
vm.stopPrank();
vm.startPrank(address(0xbeef));
SafeERC20.safeApprove(address(vault), address(alchemist), 200e18 + 100e18);
alchemist.deposit(200e18, address(0xbeef), 0);
uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT));
alchemist.mint(tokenId, 180e18, address(0xbeef));
vm.stopPrank();
// Create redemption to earmark debt
vm.startPrank(address(0xdad));
SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 180e18);
transmuterLogic.createRedemption(180e18);
vm.stopPrank();
// Skip to full maturation of redemption
vm.roll(block.number + 5_256_000);
// Manipulate price to trigger undercollateralization and force repay
uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply();
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply);
uint256 modifiedVaultSupply = (initialVaultSupply * 590 / 10_000) + initialVaultSupply;
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply);
// Liquidate - this triggers _forceRepay() which has the accounting bug
vm.startPrank(externalUser);
alchemist.liquidate(tokenId);
vm.stopPrank();
// Complete the redemption process
vm.roll(block.number + 5_256_000);
vm.startPrank(address(0xdad));
transmuterLogic.claimRedemption(1);
vm.stopPrank();
// NOW DRAIN ALL LIQUIDITY: 0xbeef withdraws remaining collateral
vm.startPrank(address(0xbeef));
(uint256 remainingCollateral, uint256 remainingDebt,) = alchemist.getCDP(tokenId);
console.log("0xbeef remaining collateral before withdraw:", remainingCollateral);
console.log("0xbeef remaining debt before withdraw:", remainingDebt);
// If there's remaining debt, repay it first
if (remainingDebt > 0) {
// Convert debt to yield tokens and repay
uint256 debtInYield = alchemist.convertDebtTokensToYield(remainingDebt);
if (vault.balanceOf(address(0xbeef)) >= debtInYield) {
alchemist.repay(debtInYield, tokenId);
}
}
// Withdraw all remaining collateral
(remainingCollateral, remainingDebt,) = alchemist.getCDP(tokenId);
if (remainingCollateral > 0) {
alchemist.withdraw(remainingCollateral, address(0xbeef), tokenId);
}
vm.stopPrank();
// Verify ALL liquidity is drained from the system
uint256 totalSystemDebt = alchemist.totalDebt();
uint256 totalSystemCollateral = alchemist.getTotalDeposited();
console.log("=== SYSTEM STATE AFTER ALL WITHDRAWALS ===");
console.log("Total system debt:", totalSystemDebt);
console.log("Total system collateral:", totalSystemCollateral);
// BUG DEMONSTRATION: Even after ALL parties have withdrawn everything,
// _mytSharesDeposited still shows phantom tokens that don't exist
uint256 accountingBalance = alchemist._mytSharesDeposited();
uint256 realBalance = IERC20(address(vault)).balanceOf(address(alchemist)) + IERC20(address(vault)).balanceOf(address(transmuterLogic));
console.log("=== FORCE REPAY ACCOUNTING BUG ===");
console.log("_mytSharesDeposited (accounting):", accountingBalance);
console.log("Actual vault balance (reality):", realBalance);
console.log("Phantom tokens:", accountingBalance > realBalance ? accountingBalance - realBalance : 0);
// The bug: _mytSharesDeposited tracks more tokens than actually exist
// This happens because _forceRepay() transfers tokens out but doesn't update _mytSharesDeposited
// Even with ZERO system debt and collateral, _mytSharesDeposited > 0
assertGt(accountingBalance, 0, "BUG: _mytSharesDeposited should be 0 when no liquidity remains, but force repay leaves phantom tokens");
assertEq(realBalance, 0, "Vault should be empty after all withdrawals");
}