Summary
When a borrower under liquidation repays only a partial amount of their debt, the remaining debt may be too small to incentivize liquidators. As a result, the bad debt (the portion of the debt that is not repaid) may not be properly accounted for or liquidated, allowing borrowers to bypass bad debt accounting and leave the protocol with unresolved undercollateralized positions.
Vulnerability Details
Root Cause:
The liquidation mechanism does not enforce full repayment of debt when a borrower is under liquidation.
Borrowers can repay a partial amount of their debt, leaving a small remaining debt that is not worth liquidating due to low rewards for liquidators.
The protocol does not have a mechanism to force the liquidation of the remaining bad debt.
Impact
Bad Debt Accumulation: Small, unresolved bad debts can accumulate in the protocol, increasing the risk to the system.
Loss of Funds: The protocol may incur losses if the bad debt is not liquidated and the collateral is insufficient to cover the debt.
Inefficient Liquidation: Liquidators may avoid small positions, leaving them un-liquidated and increasing the risk to the protocol.
PoC
A borrower's health factor falls below the liquidation threshold, and liquidation is initiated.
The borrower repays a partial amount of their debt, reducing the remaining debt to a very small amount (e.g., 1 wei).
The remaining debt is too small to incentivize liquidators, so it remains un-liquidated.
The protocol is left with unresolved bad debt, increasing the risk to the system.
Tools Used
Manual Review
Recommendations
1. Force Full Repayment During Liquidation:
function closeLiquidation() external nonReentrant whenNotPaused {
address userAddress = msg.sender;
UserData storage user = userData[userAddress];
if (!user.underLiquidation) revert NotUnderLiquidation();
ReserveLibrary.updateReserveState(reserve, rateData);
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
if (userDebt > DUST_THRESHOLD) revert DebtNotZero();
user.underLiquidation = false;
user.liquidationStartTime = 0;
emit LiquidationClosed(userAddress);
}
2. Automatically Liquidate Remaining Debt:
function repay(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (amount == 0) revert InvalidAmount();
UserData storage user = userData[msg.sender];
if (user.scaledDebtBalance == 0) revert UserNotFound();
ReserveLibrary.updateReserveState(reserve, rateData);
uint256 userDebt = IDebtToken(reserve.reserveDebtTokenAddress).balanceOf(msg.sender);
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount;
if (actualRepayAmount < DUST_THRESHOLD) revert RepayAmountBelowDustThreshold();
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(msg.sender, amount, reserve.usageIndex);
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
reserve.totalUsage = newTotalSupply;
user.scaledDebtBalance -= amountBurned;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
if (user.underLiquidation && user.scaledDebtBalance.rayMul(reserve.usageIndex) <= DUST_THRESHOLD) {
_finalizeLiquidation(userAddress);
}
emit Repay(msg.sender, msg.sender, actualRepayAmount);
}
3. Increase Incentives for Small Liquidations:
function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
if (userAddress == address(0)) revert InvalidAddress();
UserData storage user = userData[userAddress];
if (!user.underLiquidation) revert NotUnderLiquidation();
ReserveLibrary.updateReserveState(reserve, rateData);
if (block.timestamp <= user.liquidationStartTime + liquidationGracePeriod) {
revert GracePeriodNotExpired();
}
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
if (userDebt <= DUST_THRESHOLD) revert DebtAlreadyRepaid();
uint256 liquidationIncentive = calculateLiquidationIncentive(userDebt);
user.underLiquidation = false;
user.liquidationStartTime = 0;
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
user.depositedNFTs[tokenId] = false;
raacNFT.transferFrom(address(this), stabilityPool, tokenId);
}
delete user.nftTokenIds;
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, stabilityPool, liquidationIncentive);
user.scaledDebtBalance -= amountBurned;
reserve.totalUsage = newTotalSupply;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit LiquidationFinalized(stabilityPool, userAddress, userDebt, getUserCollateralValue(userAddress));
}