Core Contracts

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

Users Can Lose Funds and Collateral by Repaying Loans After Liquidation Grace Period Expiry

Summary

Users can lose money if they repay their loan after the liquidation grace period has passed. This occurs because the repayment does not close the liquidation process, and the Stability Pool is the only entity that can finalize the liquidation after GRACE_PERIOD by calling the LendingPool::finalizeLiquidation function on the LendingPool contract. Additionally, if a user repays after the grace period, the Stability Pool's StabilityPool::liquidateBorrower function will fail, leaving the NFT collateral stuck and unrecoverable by either the user or the Stability Pool it is worthy to note that calling the finalizeLiquidation instead of the liquidateBorrower doesn't have this issue of the user being unable to be liquidated. This issue also affects third parties who attempt to repay the debt on behalf of the user after the grace period, resulting in a loss of funds for them as well.

This can also occur as user may pay for their debt and not call closeLiquidation before the GRACE_PERIOD ends and they also lose their paid money and collaterized NFT.
Affected Code:

Vulnerability Details

The vulnerability arises due to the lack of a check in the repay function to prevent users from repaying their debt after the liquidation grace period has expired. This allows users to repay their debt even when the liquidation process cannot be finalized, leading to a loss of funds and locked collateral.

POC

Paste the following code into the LendingPool.test.js file.

describe("Liquidation", function () {
beforeEach(async function () {
// User2 deposits into the reserve pool to provide liquidity
const depositAmount = ethers.parseEther("1000");
await crvusd.connect(user2).approve(lendingPool.target, depositAmount);
await lendingPool.connect(user2).deposit(depositAmount);
// User1 deposits NFT and borrows
const tokenId = 1;
await raacNFT.connect(user1).approve(lendingPool.target, tokenId);
await lendingPool.connect(user1).depositNFT(tokenId);
const borrowAmount = ethers.parseEther("80");
await lendingPool.connect(user1).borrow(borrowAmount);
// Users approve crvUSD for potential transactions
await crvusd.connect(user2).approve(lendingPool.target, ethers.parseEther("1000"));
await crvusd.connect(owner).approve(lendingPool.target, ethers.parseEther("1000"));
});
it.only("should allow user to repay debt even after grace period but can't liquidate", async function () {
// Decrease house price to trigger liquidation
await raacHousePrices.setHousePrice(1, ethers.parseEther("90"));
// Attempt to initiate liquidation
await expect(lendingPool.connect(user2).initiateLiquidation(user1.address))
.to.emit(lendingPool, "LiquidationInitiated")
.withArgs(user2.address, user1.address);
// Verify that the user is under liquidation
expect(await lendingPool.isUnderLiquidation(user1.address)).to.be.true;
// Verify that the user cannot withdraw NFT while under liquidation
await expect(lendingPool.connect(user1).withdrawNFT(1))
.to.be.revertedWithCustomError(lendingPool, "CannotWithdrawUnderLiquidation");
// Verify the liquidation start time is set
const liquidationStartTime = await lendingPool.liquidationStartTime(user1.address);
expect(liquidationStartTime).to.be.gt(0);
// Verify the health factor is below the liquidation threshold
const healthFactor = await lendingPool.calculateHealthFactor(user1.address);
const healthFactorLiquidationThreshold = await lendingPool.healthFactorLiquidationThreshold();
expect(healthFactor).to.be.lt(healthFactorLiquidationThreshold);
// Advance time beyond grace period (72 hours)
await ethers.provider.send("evm_increaseTime", [72 * 60 * 60 + 1]);
await ethers.provider.send("evm_mine");
// Verify that the user is still under liquidation
expect(await lendingPool.isUnderLiquidation(user1.address)).to.be.true;
// User1 repays the debt
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"));
// User1 closes the liquidation
await expect(lendingPool.connect(user1).closeLiquidation())
.to.be.revertedWithCustomError(lendingPool, "GracePeriodExpired");
// Verify that the user is still under liquidation despite repayment
expect(await lendingPool.isUnderLiquidation(user1.address)).to.be.true;
// Fund the stability pool with crvUSD
await crvusd.connect(owner).mint(owner.address, ethers.parseEther("1000"));
// Set Stability Pool address (using owner for this test)
await lendingPool.connect(owner).setStabilityPool(owner.address);
await expect(lendingPool.connect(owner).finalizeLiquidation(user1.address))
.to.emit(lendingPool, "LiquidationFinalized");
});
});

Impact

  • Users can lose funds if they repay their loan after the liquidation grace period has passed.

  • The NFT collateral can become stuck and cannot be withdrawn by either the user or the Stability Pool.

  • Third parties who attempt to repay the debt on behalf of the user after the grace period also lose their funds.

Tools Used

  • Hardhat

  • Manual Review

Recommendations

  1. Add a Check in the repay Function: Implement a check in the repay function to prevent users from repaying their debt after the liquidation grace period has expired. This can be done by verifying the liquidation status and the elapsed time since the liquidation started.

function _repay(uint256 amount, address onBehalfOf) internal {
if (amount == 0) revert InvalidAmount();
if (onBehalfOf == address(0)) revert AddressCannotBeZero();
UserData storage user = userData[onBehalfOf];
// Check if the user is under liquidation and if the grace period has expired
if (user.isUnderLiquidation && block.timestamp > user.liquidationStartTime + GRACE_PERIOD) {
revert GracePeriodExpired();
}
// other code
}
  1. Add a check in liquidateBorrower() and finalizeLiquidation() to reset liquidation status when userDebt == 0 && user.isUnderLiquidation. For example:

if (userDebt == 0 && user.isUnderLiquidation) {
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
}

This prevents users from remaining in liquidation status after their debt is cleared by the user or another user and they failed to call closeLiquidation before the period expired.

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 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.