Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: medium
Valid

LendingPool: to avoid liquidation, user need to fully repay his debt, user can be unfairly liquidated if he only reimburse part of it

Summary

Once a user enter liquidation state isUnderLiquidation[userAddress] = true, the only way to get out of liquidation is by calling closeLiquidation(). This function check if user has no more debt left. So it's impossible for a user to partially reimburse his debt, he has to fully reimburse it.

The issue here is that a user can still be unfairly liquidated if he partially reimburse his debt using repay().

Vulnerability Details

finalizeLiquidation() doesn't check the healthRatio of a user before liquidation. As we deal with NFTs representing houses, the value of debts taken against will be extensive. Not allowing users to partially repay to avoid liquidation is unfair and can lead to improper liquidation

  • userA deposit NFT, with a home value of 100_000$

  • userA borrows 70_000$ against it.

  • market event, NFT drops in value to 80_000$

  • userA enter liquidation state (80_000*0.8 = 64_000$) -> healtRatio = 0.91

  • userA repay 10_000$, debt is now 60000 -> healtRatio = 1.06, before gracePeriod

  • userA tries to closeLiquidation(), it will revert as userA needs to repay his debt fully

  • gracePeriod pass, userA is liquidated by finalizeLiquidation() and loses his NFT/ home property unfairly

Impact

The way the liquidation state is managed makes it impossible for users to avoid unfair liquidation. A user should be able to partially repay his loans to avoid liquidation. This is exacerbated by the fact that in RAAC, users borrow against home values, which are most of the time high values. If there is a small drop in the housing market, users may not 80% of the value of the home at hand to fully repay their loans.

Tools Used

Manual

Recommendations

closeLiquidation() and finalizeLiquidation() should check the healthFactor of users to avoid unfair liquidation:

function closeLiquidation() external nonReentrant whenNotPaused {
address userAddress = msg.sender;
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
// update state
ReserveLibrary.updateReserveState(reserve, rateData);
if (block.timestamp > liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodExpired();
}
UserData storage user = userData[userAddress];
- uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
- if (userDebt > DUST_THRESHOLD) revert DebtNotZero();
+ uint256 healthFactor = calculateHealthFactor(userAddress);
+ if (healthFactor <= healthFactorLiquidationThreshold) revert HealthFactorTooLow();
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
emit LiquidationClosed(userAddress);
}
function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
// update state
ReserveLibrary.updateReserveState(reserve, rateData);
if (block.timestamp <= liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodNotExpired();
}
+ uint256 healthFactor = calculateHealthFactor(userAddress);
+ if (healthFactor > healthFactorLiquidationThreshold) revert HealthFactorHealthy();
UserData storage user = userData[userAddress];
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
// Transfer NFTs to Stability Pool
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;
// Burn DebtTokens from the user
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) = IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
// Transfer reserve assets from Stability Pool to cover the debt
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
// Update user's scaled debt balance
user.scaledDebtBalance -= amountBurned;
reserve.totalUsage = newTotalSupply;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit LiquidationFinalized(stabilityPool, userAddress, userDebt, getUserCollateralValue(userAddress));
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

LendingPool::finalizeLiquidation() never checks if debt is still unhealthy

Support

FAQs

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