Description
LiquidationPoolManager::runLiquidation
is called by the SmartVaultManager
when liquidating a vault. This function loops over all collateral tokens and transfers non-zero balances to the LiquidationPool
contract for distribution between stakers before forwarding the remaining balances to the protocol treasury.
function runLiquidation(uint256 _tokenId) external {
ISmartVaultManager manager = ISmartVaultManager(smartVaultManager);
manager.liquidateVault(_tokenId);
distributeFees();
ITokenManager.Token[] memory tokens = ITokenManager(manager.tokenManager()).getAcceptedTokens();
ILiquidationPoolManager.Asset[] memory assets = new ILiquidationPoolManager.Asset[](tokens.length);
uint256 ethBalance;
for (uint256 i = 0; i < tokens.length; i++) {
ITokenManager.Token memory token = tokens[i];
if (token.addr == address(0)) {
ethBalance = address(this).balance;
if (ethBalance > 0) assets[i] = ILiquidationPoolManager.Asset(token, ethBalance);
} else {
IERC20 ierc20 = IERC20(token.addr);
uint256 erc20balance = ierc20.balanceOf(address(this));
if (erc20balance > 0) {
assets[i] = ILiquidationPoolManager.Asset(token, erc20balance);
ierc20.approve(pool, erc20balance);
}
}
}
LiquidationPool(pool).distributeAssets{value: ethBalance}(assets, manager.collateralRate(), manager.HUNDRED_PC());
forwardRemainingRewards(tokens);
}
LiquidationPool::distributeAssets
attempts to liquidate each collateral token based on the proportional staked balance of staked positions. Here, it is important to note that stakeTotal
considers both TST and EURO deposits; however, if the EURO balance of a given position is not sufficient to cover the calculated portion then this is readjusted. It is, therefore, not unlikely to encounter a situation where the total staked EURO is insufficient to cover the liquidation, and thus, the vault will be only partially liquidated.
function distributeAssets(ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC) external payable {
consolidatePendingStakes();
(,int256 priceEurUsd,,,) = Chainlink.AggregatorV3Interface(eurUsd).latestRoundData();
uint256 stakeTotal = getStakeTotal();
uint256 burnEuros;
uint256 nativePurchased;
for (uint256 j = 0; j < holders.length; j++) {
Position memory _position = positions[holders[j]];
uint256 _positionStake = stake(_position);
if (_positionStake > 0) {
for (uint256 i = 0; i < _assets.length; i++) {
ILiquidationPoolManager.Asset memory asset = _assets[i];
if (asset.amount > 0) {
(,int256 assetPriceUsd,,,) = Chainlink.AggregatorV3Interface(asset.token.clAddr).latestRoundData();
uint256 _portion = asset.amount * _positionStake / stakeTotal;
uint256 costInEuros = _portion * 10 ** (18 - asset.token.dec) * uint256(assetPriceUsd) / uint256(priceEurUsd)
* _hundredPC / _collateralRate;
if (costInEuros > _position.EUROs) {
_portion = _portion * _position.EUROs / costInEuros;
costInEuros = _position.EUROs;
}
_position.EUROs -= costInEuros;
rewards[abi.encodePacked(_position.holder, asset.token.symbol)] += _portion;
burnEuros += costInEuros;
if (asset.token.addr == address(0)) {
nativePurchased += _portion;
} else {
IERC20(asset.token.addr).safeTransferFrom(manager, address(this), _portion);
}
}
}
}
positions[holders[j]] = _position;
}
if (burnEuros > 0) IEUROs(EUROs).burn(address(this), burnEuros);
returnUnpurchasedNative(_assets, nativePurchased);
}
At this stage, the unliquidated assets are returned to the LiquidationPoolManager
contract and execution continues to the call to LiquidationPoolManager::forwardRemainingRewards
which forwards the remaining native/ERC20 token balances to the protocol treasury.
function forwardRemainingRewards(ITokenManager.Token[] memory _tokens) private {
for (uint256 i = 0; i < _tokens.length; i++) {
ITokenManager.Token memory _token = _tokens[i];
if (_token.addr == address(0)) {
uint256 balance = address(this).balance;
if (balance > 0) {
(bool _sent,) = protocol.call{value: balance}("");
require(_sent);
}
} else {
uint256 balance = IERC20(_token.addr).balanceOf(address(this));
if (balance > 0) IERC20(_token.addr).transfer(protocol, balance);
}
}
}
Therefore, if the vault is not fully liquidated due to insufficient stake in the liquidation pool, the excess unliquidated collateral will simply be lost to the protocol treasury. Additionally, there is no guarantee that the debt position is fully repaid since the value of the seized collateral is likely such that it is less than the amount of EURO minted by the vault, so the protocol will accumulate bad debt.
Impact
This issue can result in bad debt for the protocol, and so is of high severity.
Recommended Mitigation
Ensure there is sufficient stake in the LiquidationPool
contract to cover comparatively large liquidations and consider handling partial liquidations separately when the costInEuros
sum does not equal the value in Euros to liquidate.