at _liquidate the MYT would get sent into transmuter, protocolFeeReceiver and the caller depending on the case. however the transfer into protocolFeeReceiver and caller does not update _mytSharesDeposited making each time earmark used for repayment inside _liquidate it would reduce the actual MYT holding of Alchemist contract but not reflected in _mytSharesDeposited
Vulnerability Details
in the function _liquidate we can see that MYT that act as fee get sent outside the contract:
uint256 repaidAmountInYield =0;if(account.earmarked >0){@> repaidAmountInYield =_forceRepay(accountId, account.earmarked);}// If debt is fully cleared, return with only the repaid amount, no liquidation needed, caller receives repayment feeif(account.debt ==0){ feeInYield =_resolveRepaymentFee(accountId, repaidAmountInYield);@> TokenUtils.safeTransfer(myt,msg.sender, feeInYield);return(repaidAmountInYield, feeInYield,0);}// Recalculate ratio after any repayment to determine if further liquidation is needed collateralInUnderlying =totalValue(accountId); collateralizationRatio = collateralInUnderlying * FIXED_POINT_SCALAR / account.debt;if(collateralizationRatio <= collateralizationLowerBound){// Do actual liquidationreturn_doLiquidation(accountId, collateralInUnderlying, repaidAmountInYield);}else{// Since only a repayment happened, send repayment fee to caller feeInYield =_resolveRepaymentFee(accountId, repaidAmountInYield);@> TokenUtils.safeTransfer(myt,msg.sender, feeInYield);return(repaidAmountInYield, feeInYield,0);}
there are no issue if the receiver is Transmuter contract as it would correctly handle the _mytSharesDeposited at later via redeem.
the issue is that sending MYT as feeInYield and as part of protocol fee inside _forceRepay . as we can see there are no _mytSharesDeposited update after the transfer is done.
Impact Details
given how _mytSharesDeposited is used in _getTotalUnderlyingValue which is also used in _doLiquidation function, over course of many liquidation happening:
calculateLiquidation would overestimate the alchemistCurrentCollateralization potentially making the check if alchemistCurrentCollateralization < alchemistMinimumCollateralization returning false while it should be true. preventing the detection if the whole contract is undercollateralized and thus preventing full liquidation of any position.
failing to detect global undercollateralization would makes protocol prone to insolvency
inflated _mytSharesDeposited would cause deposit cap check an issue
an issue with withdraw function because of the unsynced shares deposited vs collateralBalance
add this test to src/test/AlchemistV3.t.sol. this test is duplicate of testLiquidate_Undercollateralized_Position_With_Earmarked_Debt_Sufficient_Repayment_With_Protocol_Fee , adding a few assert at the bottom of the test:
run with forge test --mt testModify_Liquidate_Undercollateralized_Position_With_Earmarked_Debt_Sufficient_Repayment_With_Protocol_Fee
this prove that the MYT to underlying value that is stored in contract state is inflated if compared to actual MYT to underlying value from contract balance
function testModify_Liquidate_Undercollateralized_Position_With_Earmarked_Debt_Sufficient_Repayment_With_Protocol_Fee() external {
uint256 amount = 200_000e18; // 200,000 yvdai
// uint256 protocolFee = 100; // 10%
vm.prank(alOwner);
alchemist.setProtocolFee(protocolFee);
vm.startPrank(someWhale);
IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale);
vm.stopPrank();
// just ensureing global alchemist collateralization stays above the minimum required for regular liquidations
// no need to mint anything
vm.startPrank(yetAnotherExternalUser);
SafeERC20.safeApprove(address(vault), address(alchemist), amount * 2);
alchemist.deposit(amount, yetAnotherExternalUser, 0);
vm.stopPrank();
vm.startPrank(address(0xbeef));
SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18);
alchemist.deposit(amount, address(0xbeef), 0);
// a single position nft would have been minted to 0xbeef
uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT));
uint256 mintAmount = alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization;
alchemist.mint(tokenIdFor0xBeef, mintAmount, address(0xbeef));
vm.stopPrank();
// Need to start a transmutator deposit, to start earmarking debt
vm.startPrank(anotherExternalUser);
SafeERC20.safeApprove(address(alToken), address(transmuterLogic), mintAmount);
transmuterLogic.createRedemption(mintAmount);
vm.stopPrank();
uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic));
// skip to a future block. Lets say 60% of the way through the transmutation period (5_256_000 blocks)
vm.roll(block.number + (5_256_000 * 60 / 100));
// Earmarked debt should be 60% of the total debt
(uint256 prevCollateral, uint256 prevDebt, uint256 earmarked) = alchemist.getCDP(tokenIdFor0xBeef);
require(earmarked == prevDebt * 60 / 100, "Earmarked debt should be 60% of the total debt");
// modify yield token price via modifying underlying token supply
uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply();
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply);
// increasing yeild token suppy by 59 bps or 5.9% while keeping the unederlying supply unchanged
uint256 modifiedVaultSupply = (initialVaultSupply * 590 / 10_000) + initialVaultSupply;
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply);
// ensure initial debt is correct
vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss);
// snapshot alchemist MYT balance before liquidation
uint256 alchemistMYTBalBefore = vault.balanceOf(address(alchemist));
// let another user liquidate the previous user position
vm.startPrank(externalUser);
uint256 credit = earmarked > prevDebt ? prevDebt : earmarked;
uint256 creditToYield = alchemist.convertDebtTokensToYield(credit);
uint256 protocolFeeInYield = (creditToYield * protocolFee / BPS);
uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser));
uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser));
(uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef);
(uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenIdFor0xBeef);
uint256 repaymentFee = alchemist.convertDebtTokensToYield(earmarked) * 100 / BPS;
vm.stopPrank();
// ensure debt is reduced only by the repayment of max earmarked amount
vm.assertApproxEqAbs(debt, prevDebt - earmarked, minimumDepositOrWithdrawalLoss);
// ensure depositedCollateral is reduced only by the repayment of max earmarked amount
vm.assertApproxEqAbs(
depositedCollateral,
prevCollateral - alchemist.convertDebtTokensToYield(earmarked) - protocolFeeInYield - repaymentFee,
minimumDepositOrWithdrawalLoss
);
// ensure assets is equal to repayment of max earmarked amount
// vm.assertApproxEqAbs(assets, alchemist.convertDebtTokensToYield(earmarked), minimumDepositOrWithdrawalLoss);
// ensure liquidator fee is correct (i.e.0, since only a repayment is done)
vm.assertApproxEqAbs(feeInYield, repaymentFee, 1e18);
vm.assertEq(feeInUnderlying, 0);
// liquidator gets correct amount of fee, i.e. 0
_validateLiquidiatorState(
externalUser,
liquidatorPrevTokenBalance,
liquidatorPrevUnderlyingBalance,
feeInYield,
feeInUnderlying,
assets,
alchemist.convertDebtTokensToYield(earmarked)
);
vm.assertEq(alchemistFeeVault.totalDeposits(), 10_000 ether);
// transmuter recieves the liquidation amount in yield token minus the fee
vm.assertApproxEqAbs(
IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + alchemist.convertDebtTokensToYield(earmarked), 1e18
);
// check protocolfeereciever received the protocl fee transfer from _forceRepay
vm.assertApproxEqAbs(IERC20(address(vault)).balanceOf(address(protocolFeeReceiver)), protocolFeeInYield, 1e18);
// check the total MYT alchemist contract have after the liquidation
uint256 alchemistMYTBalAfter = vault.balanceOf(address(alchemist));
uint256 amountMYTOut = alchemistMYTBalBefore - alchemistMYTBalAfter;
// assert that amountMYTOut is the same as the amount sent to transmuter + fee
vm.assertApproxEqAbs(amountMYTOut, alchemist.convertDebtTokensToYield(earmarked) + protocolFeeInYield + repaymentFee, 1e18);
// check the state of totalUnderlyingValue and compared to actual MYT holding after liquidation repayment
uint256 alchemistMYTToUnderlying = alchemist.convertYieldTokensToUnderlying(alchemistMYTBalAfter);
uint256 alchemistTotalUnderlyingValue = alchemist.getTotalUnderlyingValue();
vm.assertApproxEqAbs(alchemistMYTToUnderlying, alchemistTotalUnderlyingValue, 1e18, "after liquidation repayment, alchemist MYT holding does not match the _getTotalUnderlyingValue");
}
Failing tests:
Encountered 1 failing test in src/test/AlchemistV3.t.sol:AlchemistV3Test
[FAIL: after liquidation repayment, alchemist MYT holding does not match the _getTotalUnderlyingValue: 267554825306893295188984 !~= 377714825306893295200000 (max delta: 1000000000000000000, real delta: 110160000000000000011016)] testModify_Liquidate_Undercollateralized_Position_With_Earmarked_Debt_Sufficient_Repayment_With_Protocol_Fee() (gas: 3901034)
Encountered a total of 1 failing tests, 0 tests succeeded