The Standard

The Standard
DeFiHardhat
20,000 USDC
View results
Submission Details
Severity: high
Invalid

Partially-liquidated vaults could result in bad debt or the erroneous seizing of entire vault collateral balance

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.

Updates

Lead Judging Commences

hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Bad-debt

hrishibhat Lead Judge
over 1 year ago
hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.