58735 sc insight calculateliquidation reverts due to divide by zero if targetcollateralization fixed point scalar
Submitted on Nov 4th 2025 at 10:32:59 UTC by @randomnpc for Audit Comp | Alchemix V3
- Report ID: #58735
- Report Type: Smart Contract
- Report severity: Insight
- Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi\\_audit/src/AlchemistV3.sol
- Impacts:
- Protocol insolvency
Description
Brief/Intro
calculateLiquidation reverts due to divide by ZERO if targetCollateralization = FIXED_POINT_SCALAR .
Vulnerability Details
In the calculateLiquidation() function, denom is calculated as:
function calculateLiquidation(
uint256 collateral,
uint256 debt,
uint256 targetCollateralization,
uint256 alchemistCurrentCollateralization,
uint256 alchemistMinimumCollateralization,
uint256 feeBps
) public pure returns (uint256 grossCollateralToSeize, uint256 debtToBurn, uint256 fee, uint256 outsourcedFee) {
...
// denom = m - 1 => (targetCollateralization - FIXED_POINT_SCALAR)/FIXED_POINT_SCALAR
uint256 denom = targetCollateralization - FIXED_POINT_SCALAR;
// debtToBurn = (num * FIXED_POINT_SCALAR) / denom
debtToBurn = (num * FIXED_POINT_SCALAR) / denom;
...
If targetCollateralization = FIXED_POINT_SCALAR, denom == 0. And targetCollateralization can be set to FIXED_POINT_SCALAR.
/// @inheritdoc IAlchemistV3AdminActions
function setMinimumCollateralization(uint256 value) external onlyAdmin {
_checkArgument(value >= FIXED_POINT_SCALAR);
minimumCollateralization = value;
emit MinimumCollateralizationUpdated(value);
}
Thus, if denom == 0, while calculating debtToBurn, divide by ZERO occurs:
debtToBurn = (num * FIXED_POINT_SCALAR) / denom; @audit can be divide by zero
Impact Details
When targetCollateralization == minimumCollateralization == FIXED_POINT_SCALAR, all of the liquidations will revert. undercollateralized positions can't be liquidated. Bad debt will start accumulating and the protocol solvency will be at risk.
References
Proof of Concept
Proof of Concept
function test_PocDivideByZeroInCalculateLiquidation() external {
vm.startPrank(someWhale);
IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale);
vm.stopPrank();
// Set minimumCollateralization to exactly 1e18 (100%)
vm.prank(alOwner);
alchemist.setMinimumCollateralization(1e18);
uint256 depositAmount = 1000e18;
// Create large healthy position to ensure global collateralization is good
vm.startPrank(yetAnotherExternalUser);
SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 100);
alchemist.deposit(depositAmount * 50, yetAnotherExternalUser, 0);
vm.stopPrank();
// Create position where collateral is slightly above debt but not enough after fee
vm.startPrank(address(0xbeef));
SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount);
alchemist.deposit(depositAmount, address(0xbeef), 0);
uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT));
// Mint debt that is close to collateral value
uint256 debtAmount = 980e18;
alchemist.mint(tokenId, debtAmount, address(0xbeef));
vm.stopPrank();
// Small price drop to make collateral slightly less but still > debt
uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply();
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply);
uint256 modifiedVaultSupply = (initialVaultSupply * 50 / 10_000) + initialVaultSupply;
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply);
// Trigger actual liquidation which internally calls calculateLiquidation
vm.startPrank(externalUser);
vm.expectRevert();
alchemist.liquidate(tokenId);
vm.stopPrank();
}
Paste the above code in AlchemistV3.t.sol file, setup MAINNET_RPC_URL and run it using:
FOUNDRY_PROFILE=default forge test --fork-url $MAINNET_RPC_URL --match-path src/test/AlchemistV3.t.sol --match-test test_PocDivideByZeroInCalculateLiquidation -vv --evm-version cancun