Core Contracts

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

The liquidation mechanism liquidates all the deposited NFT's of the borrower even if they repaid enough of their debt to regain a healthy LTV ratio

Summary

The protocol enforces liquidation of all borrower collateral (NFTs) after the GRACE_PERIOD, even if the borrower has significantly repaid their debt and restored a healthy loan-to-value ratio. This issue prevents borrowers from recovering their collateral, resulting in unfair liquidations.

Vulnerability Details

When a borrower's collateral value drops, and their LTV surpasses the liquidation threshold (80%), liquidation is initiated. The borrower has a GRACE_PERIOD (3 days) to repay the debt and close the liquidation. However, even if the borrower successfully repays enough debt to restore their health factor above 1e18, they cannot stop the liquidation from finalizing and all of their NFTs would get liquidated.

LendingPool::finalizeLiquidation

function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
...SKIP...
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
// Transfer NFTs to Stability Pool
// @audit-issue even if the debt is partially repayed all the NFT's will be transferred to the 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;
...SKIP...
}

https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/pools/LendingPool/LendingPool.sol#L496-L536

PoC

import { expect, use } from 'chai';
import hre from "hardhat";
import pkg from "hardhat";
const { network } = pkg;
const { ethers } = hre;
describe("RAAC PoC", function() {
let owner, user1, user2, user3;
let crvusd, raacNFT, raacHousePrices, stabilityPool
let lendingPool, rToken, debtToken;
let token;
beforeEach(async function () {
[owner, user1, user2, user3] = await ethers.getSigners();
const CrvUSDToken = await ethers.getContractFactory("crvUSDToken");
crvusd = await CrvUSDToken.deploy(owner.address);
await crvusd.setMinter(owner.address);
token = crvusd;
const RAACHousePrices = await ethers.getContractFactory("RAACHousePrices");
raacHousePrices = await RAACHousePrices.deploy(owner.address);
const RAACNFT = await ethers.getContractFactory("RAACNFT");
raacNFT = await RAACNFT.deploy(crvusd.target, raacHousePrices.target, owner.address);
stabilityPool = { target: owner.address };
const RToken = await ethers.getContractFactory("RToken");
rToken = await RToken.deploy("RToken", "RToken", owner.address, crvusd.target);
const DebtToken = await ethers.getContractFactory("DebtToken");
debtToken = await DebtToken.deploy("DebtToken", "DT", owner.address);
const RAACToken = await ethers.getContractFactory("RAACToken");
const raacToken = await RAACToken.deploy(owner.address, 100, 50);
const DEToken = await ethers.getContractFactory("DEToken");
const deToken = await DEToken.deploy("DEToken", "DEToken", owner.address, rToken.target);
const initialPrimeRate = ethers.parseUnits("0.1", 27);
const LendingPool = await ethers.getContractFactory("LendingPool");
lendingPool = await LendingPool.deploy(
crvusd.target,
rToken.target,
debtToken.target,
raacNFT.target,
raacHousePrices.target,
initialPrimeRate
);
const StabilityPool = await ethers.getContractFactory("StabilityPool");
stabilityPool = await StabilityPool.deploy(owner.address);
const RAACMinter = await ethers.getContractFactory("RAACMinter");
const raacMinter = await RAACMinter.deploy(
raacToken.target,
stabilityPool.target,
lendingPool.target,
owner.address
);
await stabilityPool.initialize(
rToken.target,
deToken.target,
raacToken.target,
raacMinter.target,
crvusd.target,
lendingPool.target
)
await lendingPool.setStabilityPool(owner.address);
await rToken.setReservePool(lendingPool.target);
await debtToken.setReservePool(lendingPool.target);
await rToken.transferOwnership(lendingPool.target);
await debtToken.transferOwnership(lendingPool.target);
const mintAmount = ethers.parseEther("1000");
await crvusd.mint(user1.address, mintAmount);
await crvusd.mint(user3.address, mintAmount);
await crvusd.mint(owner.address, mintAmount);
const mintAmount2 = ethers.parseEther("10000");
await crvusd.mint(user2.address, mintAmount2);
await crvusd.connect(user1).approve(lendingPool.target, mintAmount);
await crvusd.connect(user2).approve(lendingPool.target, mintAmount);
await crvusd.connect(user3).approve(lendingPool.target, mintAmount);
await raacHousePrices.setOracle(owner.address);
await raacHousePrices.setHousePrice(1, ethers.parseEther("65"));
await raacHousePrices.setHousePrice(2, ethers.parseEther("35"));
await ethers.provider.send("evm_mine", []);
const tokenId = 1;
const amountToPay1 = ethers.parseEther("65");
const amountToPay2 = ethers.parseEther("35");
await token.mint(user1.address, amountToPay1 + amountToPay2);
await token.connect(user1).approve(raacNFT.target, amountToPay1 + amountToPay2);
await raacNFT.connect(user1).mint(tokenId, amountToPay1);
await raacNFT.connect(user1).mint(tokenId + 1, amountToPay2);
// supplier deposits assets into the lending pool
const depositAmount = ethers.parseEther("1000");
await crvusd.connect(user2).approve(lendingPool.target, depositAmount);
await lendingPool.connect(user2).deposit(depositAmount);
await ethers.provider.send("evm_mine", []);
expect(await crvusd.balanceOf(rToken.target)).to.equal(ethers.parseEther("1000"));
});
it("liquidates all the collateral of the borrower even if the loan is almost paid", async () => {
// borrower provides collateral
const tokenId1 = 1;
const tokenId2 = 2;
await raacNFT.connect(user1).approve(lendingPool.target, tokenId1);
await lendingPool.connect(user1).depositNFT(tokenId1);
await raacNFT.connect(user1).approve(lendingPool.target, tokenId2);
await lendingPool.connect(user1).depositNFT(tokenId2);
// borrower borrows against the provided collateral (ltv = 80%)
const borrowAmount = ethers.parseEther("80");
await expect(lendingPool.connect(user1).borrow(borrowAmount)).to.emit(lendingPool, "Borrow");
// the user's collateral price drops by 1% so the ltv increases (81%)
await raacHousePrices.setHousePrice(1, ethers.parseEther("64"));
await expect(lendingPool.connect(user2).initiateLiquidation(user1.address))
.to.emit(lendingPool, "LiquidationInitiated")
.withArgs(user2.address, user1.address);
// Partially repay 75% of the debt
const partialRepayAmount = ethers.parseEther("60");
await expect(lendingPool.connect(user1).repay(partialRepayAmount))
.to.emit(lendingPool, "Repay")
.withArgs(user1.address, user1.address, partialRepayAmount);
const healthFactor = await lendingPool.calculateHealthFactor(user1.address);
console.log("Health factor: ", healthFactor);
// ensure health factor is >= 1e18 (liquidation threshold);
expect(healthFactor).to.be.gte(ethers.parseEther("1"));
// increase time by 3 days
await ethers.provider.send("evm_increaseTime", [72 * 60 * 60 + 1]);
await ethers.provider.send("evm_mine");
// ensure stability pool has enough assets to liquidate borrower
await crvusd.connect(owner).approve(lendingPool.getAddress(), ethers.parseEther("1000"));
await lendingPool.connect(owner).finalizeLiquidation(user1.address);
// borrower has 0 collateral left even when they almost paid their debt
const collateralValueAfterLiquidation = await lendingPool.getUserCollateralValue(user1.address);
console.log("Collateral value: ", collateralValueAfterLiquidation);
expect(collateralValueAfterLiquidation).to.eq(0);
})
});

Impact

The issue creates an unfair liquidation process, where users are penalized despite restoring their loan health.

Tools Used

VSCode, Manual research

Recommendations

In LendingPool::closeLiquidation allow the borrower to close the liquidation if they repay enough debt to regain a healthy loan-to-value ratio

Updates

Lead Judging Commences

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