Core Contracts

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

Users may not be able to close the liquidation even after repaying their loan

Summary

Users may be unable to close liquidation after repaying their loan due to grace period timing.

Vulnerability Details

When a user's loan becomes unhealthy, anyone can initiate the liquidation using initiateLiquidation. After the liquidation is initiated, there's a grace period that allows the user to repay their loan before the liquidation is finalized. For this to happen, the user must first call repay and then call closeLiquidation.

There is a check inside closeLiquidation that prevents closing the liquidation if the grace period has expired:

if (block.timestamp > liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodExpired();
}

This could be problematic if the user repays their loan close to the end of the grace period. If the repayment happens near the expiration of the grace period and the network is busy, it might take a while for the transaction to be mined. If the block.timestamp surpasses the grace period, the user's call to closeLiquidation will revert, even though the loan has been repaid.

Additionally, many other actions, like withdrawNFT, check the liquidation status of the user's loan:

function withdrawNFT(uint256 tokenId) external nonReentrant whenNotPaused {
if (isUnderLiquidation[msg.sender]) revert CannotWithdrawUnderLiquidation();

As a result, the user won't be able to withdraw their NFT even after repaying their loan, leaving the NFT stuck in the lendingPool.

Proof of Concept

To test the scenario, please add this test case to LendingPool.test.js under Liquidation suite:

it("User can't close the liquidation even after repayment", async function () {
const newValue = 2 * 24 * 60 * 60; // 2 days
await lendingPool.connect(owner).setParameter(2, newValue);
expect(await lendingPool.liquidationGracePeriod()).to.equal(newValue);
// Decrease house price and initiate liquidation
await raacHousePrices.setHousePrice(1, ethers.parseEther("90"));
await lendingPool.connect(user2).initiateLiquidation(user1.address);
// Advance time (2 days)
await ethers.provider.send("evm_increaseTime", [2 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine");
// User1 repays the debt
// now: block.timestamp == liquidationStartTime[userAddress] + liquidationGracePeriod
const userDebt = await lendingPool.getUserDebt(user1.address);
await crvusd.connect(user1).approve(lendingPool.target, userDebt + ethers.parseEther("1"));
await lendingPool.connect(user1).repay(userDebt + ethers.parseEther("1"));
// Advance time beyond grace period (1 seconds)
// now: block.timestamp > liquidationStartTime[userAddress] + liquidationGracePeriod
await ethers.provider.send("evm_increaseTime", [1]);
await ethers.provider.send("evm_mine");
// User1 can't close the liquidation
await expect(lendingPool.connect(user1).closeLiquidation())
.to.be.revertedWithCustomError(lendingPool, "GracePeriodExpired");
// The user is still under liquidation
expect(await lendingPool.isUnderLiquidation(user1.address)).to.be.true;
// The user can't withdraw the NFT
await expect(lendingPool.connect(user1).withdrawNFT(1))
.to.be.revertedWithCustomError(lendingPool, "CannotWithdrawUnderLiquidation");
// The NFT is still owned by the lendingPool
expect(await raacNFT.ownerOf(1)).to.equal(lendingPool.target);
// The user's account is cleaned
const userData = await lendingPool.userData(user1.address);
expect(userData.scaledDebtBalance).to.equal(0);
expect(userData.nftTokenIds).to.be.equal(undefined);
// The user has no remaining debt
const userClosedLiquidationDebt = await lendingPool.getUserDebt(user1.address);
expect(userClosedLiquidationDebt).to.equal(0);
// The user's health factor is now at its maximum (type(uint256).max)
const healthFactor = await lendingPool.calculateHealthFactor(user1.address);
expect(healthFactor).to.equal(ethers.MaxUint256);
});

Run the test:

npm run test:unit:pools:lendingpool -- --grep "User can't close the liquidation even after repayment"

Impact

The user's NFTs may become stuck in the lendingPool, potentially resulting in the loss of funds.

Tools Used

  • VSCode

  • Hardhat

Recommendations

Instead of having a separate function to close the liquidation, I recommend closing the liquidation automatically when the user successfully repays their loan:

function _repay(uint256 amount, address onBehalfOf) internal {
if (amount == 0) revert InvalidAmount();
if (onBehalfOf == address(0)) revert AddressCannotBeZero();
UserData storage user = userData[onBehalfOf];
// Update reserve state before repayment
ReserveLibrary.updateReserveState(reserve, rateData);
// Calculate the user's debt (for the onBehalfOf address)
uint256 userDebt = IDebtToken(reserve.reserveDebtTokenAddress).balanceOf(onBehalfOf);
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
// If amount is greater than userDebt, cap it at userDebt
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount;
uint256 scaledAmount = actualRepayAmount.rayDiv(reserve.usageIndex);
// Burn DebtTokens from the user whose debt is being repaid (onBehalfOf)
// is not actualRepayAmount because we want to allow paying extra dust and we will then cap there
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
// Transfer reserve assets from the caller (msg.sender) to the reserve
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
reserve.totalUsage = newTotalSupply;
user.scaledDebtBalance -= amountBurned;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit Repay(msg.sender, onBehalfOf, actualRepayAmount);
+ userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
+
+ if (userDebt > DUST_THRESHOLD) revert DebtNotZero();
+
+ isUnderLiquidation[onBehalfOf] = false;
+ liquidationStartTime[onBehalfOf] = 0;
+
+ emit LiquidationClosed(onBehalfOf);
}
Updates

Lead Judging Commences

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

A borrower can LendingPool::repay to avoid liquidation but might not be able to call LendingPool::closeLiquidation successfully due to grace period check, loses both funds and collateral

Support

FAQs

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

Give us feedback!