57534 sc low small debt positions cannot be liquidated due to zero amount checks on token vaults
Submitted on Oct 27th 2025 at 01:34:44 UTC by @X0sauce for Audit Comp | Alchemix V3
- Report ID: #57534
- Report Type: Smart Contract
- Report severity: Low
- Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi\\_audit/src/AlchemistV3.sol
- Impacts:
- Protocol insolvency
Description
Vulnerability Details
We notice that during liquidations of underwater CDP positions, an outsourced fee would be calculated and send to the user.
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) {
if (debt >= collateral) {
@> outsourcedFee = (debt * feeBps) / BPS;
// fully liquidate debt if debt is greater than collateral
return (collateral, debt, 0, outsourcedFee);
}
if (alchemistCurrentCollateralization < alchemistMinimumCollateralization) {
@> outsourcedFee = (debt * feeBps) / BPS;
// fully liquidate debt in high ltv global environment
return (debt, debt, 0, outsourcedFee);
}
// fee is taken from surplus = collateral - debt
uint256 surplus = collateral > debt ? collateral - debt : 0;
fee = (surplus * feeBps) / BPS;
// collateral remaining for margin‐restore calc
uint256 adjCollat = collateral - fee;
// compute m*d (both plain units)
uint256 md = (targetCollateralization * debt) / FIXED_POINT_SCALAR;
// if md <= adjCollat, nothing to liquidate
if (md <= adjCollat) {
return (0, 0, fee, 0);
}
// numerator = md - adjCollat
uint256 num = md - adjCollat;
// 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;
// gross collateral seize = net + fee
grossCollateralToSeize = debtToBurn + fee;
}
However, in the case of liquidating ALUSD debt positions, the underlying conversion factor would be 10 ** (18 - 6) = 1e12.
A user can repeatedly open up small debt positions and when those positions go underwater, the liquidation flow will consistently revert because outsourcedFee will be truncated to zero. This is because in both AlchemistTokenVault and AlchemistETHVault, both vaults include checks that would revert on 0 amounts during withdrawals to cover outsourcedFee.
This results in protocol incurring all bad debt because cannot liquidate those positions which results in protocol insolvency
function _doLiquidation(uint256 accountId, uint256 collateralInUnderlying, uint256 repaidAmountInYield)
internal
returns (uint256 amountLiquidated, uint256 feeInYield, uint256 feeInUnderlying)
{
Account storage account = _accounts[accountId];
(uint256 liquidationAmount, uint256 debtToBurn, uint256 baseFee, uint256 outsourcedFee) = calculateLiquidation(
collateralInUnderlying,
account.debt,
minimumCollateralization,
normalizeUnderlyingTokensToDebt(_getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / totalDebt,
globalMinimumCollateralization,
liquidatorFee
);
amountLiquidated = convertDebtTokensToYield(liquidationAmount);
feeInYield = convertDebtTokensToYield(baseFee);
// update user balance and debt
account.collateralBalance = account.collateralBalance > amountLiquidated ? account.collateralBalance - amountLiquidated : 0;
_subDebt(accountId, debtToBurn);
// send liquidation amount - fee to transmuter
TokenUtils.safeTransfer(myt, transmuter, amountLiquidated - feeInYield);
// send base fee to liquidator if available
if (feeInYield > 0 && account.collateralBalance >= feeInYield) {
TokenUtils.safeTransfer(myt, msg.sender, feeInYield);
}
// Handle outsourced fee from vault
if (outsourcedFee > 0) {
uint256 vaultBalance = IFeeVault(alchemistFeeVault).totalDeposits();
if (vaultBalance > 0) {
@> uint256 feeBonus = normalizeDebtTokensToUnderlying(outsourcedFee);
@> feeInUnderlying = vaultBalance > feeBonus ? feeBonus : vaultBalance;
IFeeVault(alchemistFeeVault).withdraw(msg.sender, feeInUnderlying);
}
}
emit Liquidated(accountId, msg.sender, amountLiquidated + repaidAmountInYield, feeInYield, feeInUnderlying);
return (amountLiquidated + repaidAmountInYield, feeInYield, feeInUnderlying);
}
In AlchemistTokenVault
function withdraw(address recipient, uint256 amount) external override onlyAuthorized {
_checkNonZeroAddress(recipient);
@> _checkNonZeroAmount(amount);
IERC20(token).transfer(recipient, amount);
emit Withdrawn(recipient, amount);
}
In AlchemistETHVault
function withdraw(address recipient, uint256 amount) external override onlyAuthorized nonReentrant {
@> if (amount == 0) revert ZeroAmount();
// Check if the vault has enough balance
if (amount > address(this).balance) revert InsufficientBalance();
// Send as native ETH
(bool success,) = recipient.call{value: amount}("");
if (!success) revert TransferFailed();
emit Withdrawn(recipient, amount);
}
Proof of Concept
- Create a new file
AlchemistV3ZeroAmountRevert.t.soland paste the below test - Run
forge test --mc AlchemistV3ZeroAmountRevert --mt testLiquidation_POC_outsourceFeeRevert -vvvv
// 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 AlchemistV3ZeroAmountRevert 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 = 1000e18;
// 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(6);
setUpMYT(6);
deployCoreContracts(6);
}
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: 0,
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(address(0xbeef));
whitelist.add(externalUser);
whitelist.add(anotherExternalUser);
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), address(0xbeef), accountFunds);
_magicDepositToVault(address(vault), address(0xdad), accountFunds);
_magicDepositToVault(address(vault), externalUser, accountFunds);
_magicDepositToVault(address(vault), yetAnotherExternalUser, accountFunds);
_magicDepositToVault(address(vault), anotherExternalUser, accountFunds);
vm.stopPrank();
vm.startPrank(address(admin));
allocator.allocate(address(mytStrategy), vault.convertToAssets(vault.totalSupply()));
vm.stopPrank();
deal(address(alToken), address(0xdad), accountFunds);
deal(address(alToken), address(anotherExternalUser), accountFunds);
deal(address(vault.asset()), address(0xbeef), accountFunds);
deal(address(vault.asset()), externalUser, accountFunds);
deal(address(vault.asset()), yetAnotherExternalUser, accountFunds);
deal(address(vault.asset()), anotherExternalUser, accountFunds);
deal(address(vault.asset()), alchemist.alchemistFeeVault(), 10_000 * (10 ** alchemistUnderlyingTokenDecimals));
vm.startPrank(anotherExternalUser);
SafeERC20.safeApprove(address(vault.asset()), address(vault), accountFunds);
vm.stopPrank();
vm.startPrank(yetAnotherExternalUser);
SafeERC20.safeApprove(address(vault.asset()), address(vault), accountFunds);
vm.stopPrank();
vm.startPrank(someWhale);
deal(address(vault), someWhale, whaleSupply);
deal(address(vault.asset()), someWhale, whaleSupply);
SafeERC20.safeApprove(address(vault.asset()), address(mockStrategyYieldToken), whaleSupply);
vm.stopPrank();
}
function testLiquidation_POC_outsourceFeeRevert() external {
uint256 amount = 1e12;
// 1. Whale mints to simulate MYT token price
vm.startPrank(someWhale);
IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale);
vm.stopPrank();
// 2. 0xbeef holds a borrow position
vm.startPrank(address(0xbeef));
SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18);
alchemist.deposit(amount, address(0xbeef), 0);
// 3. A single position nft would have been minted to 0xbeef
uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT));
alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef));
vm.stopPrank();
// 4. modify yield token price via modifying underlying token supply
uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply();
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply);
// 5. 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);
(uint256 deposited, uint256 userDebt, uint256 earmarked) = alchemist.getCDP(tokenIdFor0xBeef);
uint256 collateralInUnderlying = alchemist.totalValue(tokenIdFor0xBeef);
// 6. Log values
// - Debt token always in 18 precision, for ALUSD, underlying conversion factor = 10 ** (18 - 6) = 1e12
console.log("underlyingConversionFactor: ", alchemist.underlyingConversionFactor());
console.log("collateralInUnderlying: ", collateralInUnderlying);
console.log("deposited: ", deposited);
console.log("earmarked: ", earmarked);
console.log("userDebt: ", userDebt);
// 7. Reverts with 0 amount error
vm.startPrank(externalUser);
(uint256 yieldAmount, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef);
vm.stopPrank();
}
}
You'll see the below output
│ │ ├─ [9090] MockMYTVault::transfer(Transmuter: [0x2387b3383E89c164781d173B7Aa14d9c46eD2642], 0)
│ │ │ ├─ emit Transfer(from: TransparentUpgradeableProxy: [0x48c33395391C097df9c9aA887a40f1b47948D393], to: Transmuter: [0x2387b3383E89c164781d173B7Aa14d9c46eD2642], value: 0)
│ │ │ └─ ← [Return] true
│ │ ├─ [4279] AlchemistTokenVault::totalDeposits() [staticcall]
│ │ │ ├─ [2931] TestERC20::balanceOf(AlchemistTokenVault: [0xf7d155cb4cF4ab327f15B14e207932b9FeFB405a]) [staticcall]
│ │ │ │ └─ ← [Return] 10000000000 [1e10]
│ │ │ └─ ← [Return] 10000000000 [1e10]
│ │ ├─ [3479] AlchemistTokenVault::withdraw(0x69E8cE9bFc01AA33cD2d02Ed91c72224481Fa420, 0)
│ │ │ └─ ← [Revert] ZeroAmount()
│ │ └─ ← [Revert] ZeroAmount()
│ └─ ← [Revert] ZeroAmount()
└─ ← [Revert] ZeroAmount()