Core Contracts

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

NFT Liquidation Grace Period Creates Risk of Increased Protocol Losses

Summary

The LendingPool contract implements a grace period for liquidations, during which borrowers cannot be liquidated even if their collateral value continues to decline. This creates a scenario where the protocol may incur larger losses than necessary, as positions that become deeply underwater during the grace period cannot be liquidated until the period expires.

Vulnerability Details

The liquidation process in the LendingPool contract requires:

  1. Someone calls initiateLiquidation() when a position becomes unhealthy (health factor below threshold)

  2. A 3-day grace period begins (configurable up to 7 days via liquidationGracePeriod)

  3. During this period:

    • The borrower can repay their debt and call closeLiquidation()

    • No one can liquidate the position, even if collateral value drops further

  4. Only after the grace period expires can the Stability Pool call finalizeLiquidation()

  5. The Collateral value declines further during the grace period

  6. At the point where the collateral value equals the total borrowed amount + interest, the liquidation must happen to cover the debt.

  7. The grace period has not ended yet, so there is no way to liquidate the position

  8. The Collateral value declines further, below the borrowed amount => there is no reason now to for the borrower to pay back his debt because the borrowed amount is more worth than the collateral.

  9. After the grace period someone needs to cover the bad debt (most likely the RAAC protocol itself) otherwise there is a high risk of insolvency.

Key problematic code sections:

function initiateLiquidation(address userAddress) external nonReentrant whenNotPaused {
if (isUnderLiquidation[userAddress]) revert UserAlreadyUnderLiquidation();
// update state
ReserveLibrary.updateReserveState(reserve, rateData);
uint256 healthFactor = calculateHealthFactor(userAddress);
if (healthFactor >= healthFactorLiquidationThreshold) revert HealthFactorTooLow();
isUnderLiquidation[userAddress] = true;
liquidationStartTime[userAddress] = block.timestamp;
emit LiquidationInitiated(msg.sender, userAddress);
}
function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
if (block.timestamp <= liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodNotExpired();
}
// ... liquidation logic
}

The issue is that there is no mechanism to bypass or shorten the grace period, even when positions become severely underwater.

PoC

This test assumes that the issue in the StabilityPool::liquidateBorrower() function for the approval has been fixed (see issue: "StabilityPool can't liquidate positions because of wrong user debt amount being approved causing the transaction to fail")

For the purpose of this test I modified the function to approve type(uint256).max. This shouldn't be done in production and there is already a recommendation in the issue mentioned above.

Update Line 461 in the liquidateBorrower() function :

function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
- bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
+ bool approveSuccess = crvUSDToken.approve(address(lendingPool), type(uint256).max);
}

In order to run the test you need to:

  1. Run foundryup to get the latest version of Foundry

  2. Install hardhat-foundry: npm install --save-dev @nomicfoundation/hardhat-foundry

  3. Import it in your Hardhat config: require("@nomicfoundation/hardhat-foundry");

  4. Make sure you've set the BASE_RPC_URL in the .env file or comment out the forking option in the hardhat config.

  5. Run npx hardhat init-foundry

  6. There is one file in the test folder that will throw an error during compilation so rename the file in test/unit/libraries/ReserveLibraryMock.sol to => ReserveLibraryMock.sol_broken so it doesn't get compiled anymore (we don't need it anyways).

  7. Create a new folder test/foundry

  8. Paste the below code into a new test file i.e.: FoundryTest.t.sol

  9. Run the test: forge test --mc FoundryTest -vvvv

For the purpose of this test I minted all of the NFTs to a single user which deposits them into the Lendingpool but in a more realistic scenario these NFTs are owned by different users. The impact is the same => If many users provide their NFT as collateral to borrow capital and some of the House Prices start to decline, there is no way to immediately liquidate those bad positions.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {StabilityPool} from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {crvUSDToken} from "../../contracts/mocks/core/tokens/crvUSDToken.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {RAACHousePrices} from "../../contracts/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "../../contracts/core/tokens/RAACNFT.sol";
import {RToken} from "../../contracts/core/tokens/RToken.sol";
import {DebtToken} from "../../contracts/core/tokens/DebtToken.sol";
import {DEToken} from "../../contracts/core/tokens/DEToken.sol";
import {LendingPool} from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import {RAACMinter, IRAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {PercentageMath} from "../../contracts/libraries/math/PercentageMath.sol";
import {ILendingPool} from "../../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
import {IStabilityPool} from "../../contracts/interfaces/core/pools/StabilityPool/IStabilityPool.sol";
contract FoundryTest is Test {
using PercentageMath for uint256;
StabilityPool public stabilityPool;
LendingPool public lendingPool;
RAACMinter public raacMinter;
crvUSDToken public crvusd;
RToken public rToken;
DEToken public deToken;
RAACToken public raacToken;
RAACNFT public raacNFT;
DebtToken public debtToken;
RAACHousePrices public raacHousePrices;
address public owner;
address public user1;
address public user2;
address public user3;
address public treasury;
uint256 public constant INITIAL_BALANCE = 1000e18;
uint256 public constant INITIAL_PRIME_RATE = 1e27;
uint256 constant INITIAL_BATCH_SIZE = 3;
uint256 constant HOUSE_PRICE = 100e18;
uint256 constant TOKEN_ID = 1;
function setUp() public {
// Setup accounts
owner = address(this);
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
treasury = makeAddr("treasury");
// Deploy base tokens
crvusd = new crvUSDToken(owner);
crvusd.setMinter(owner);
raacToken = new RAACToken(owner, 100, 50);
// Deploy price oracle and set oracle
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner);
// Set initial house prices
raacHousePrices.setHousePrice(TOKEN_ID, HOUSE_PRICE);
// Deploy NFT
raacNFT = new RAACNFT(address(crvusd), address(raacHousePrices), owner);
// Deploy pool tokens
rToken = new RToken("RToken", "RToken", owner, address(crvusd));
debtToken = new DebtToken("DebtToken", "DT", owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
// Deploy pools
lendingPool = new LendingPool(
address(crvusd),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
INITIAL_PRIME_RATE
);
stabilityPool = new StabilityPool(owner);
// this is needed otherwise lastEmissionUpdateTimestamp will underflow in the RAACMinter constructor
vm.warp(block.timestamp + 2 days);
// Deploy RAAC minter
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner);
// Setup cross-contract references
lendingPool.setStabilityPool(address(stabilityPool));
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
deToken.transferOwnership(address(stabilityPool));
// Initialize Stability Pool
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(raacMinter),
address(crvusd),
address(lendingPool)
);
// Setup permissions
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
// Mint initial tokens and setup approvals
// also deposit crvUSD to the stability pool to get rTokens
_setupInitialBalancesAndAllowances();
}
function test_gracePeriodIssue() public {
// totalLiquidity 3000e18
// totalUsage 0
_printReserveState();
uint256 NFT_COUNT = 30;
// setup some NFTs
for (uint256 i = 0; i < NFT_COUNT; i++) {
raacHousePrices.setHousePrice(TOKEN_ID + i, HOUSE_PRICE);
}
// Setup borrower
address borrower = makeAddr("borrower");
crvusd.mint(borrower, HOUSE_PRICE * NFT_COUNT);
assertEq(crvusd.balanceOf(borrower), HOUSE_PRICE * NFT_COUNT);
// mint all NFTs to borrower for 100e18 * NFT Count
vm.startPrank(borrower);
crvusd.approve(address(raacNFT), HOUSE_PRICE * NFT_COUNT);
for (uint256 i = 0; i < NFT_COUNT; i++) {
raacNFT.mint(TOKEN_ID + i, HOUSE_PRICE);
raacNFT.approve(address(lendingPool), TOKEN_ID + i);
// deposit the NFTs to the LendingPool as collateral
lendingPool.depositNFT(TOKEN_ID + i);
}
assertEq(raacNFT.balanceOf(address(lendingPool)), NFT_COUNT);
// borrow half of the total liquidity available
lendingPool.borrow(1500e18);
vm.stopPrank();
// totalLiquidity 1500e18
// totalUsage 1500e18
_printReserveState();
// totalCollateralValueBefore 3000e18
uint256 totalCollateralValueBefore = lendingPool.getUserCollateralValue(borrower);
console2.log("totalCollateralValueBefore", totalCollateralValueBefore);
// now house prices starts dropping
for (uint256 i = 0; i < NFT_COUNT; i++) {
raacHousePrices.setHousePrice(TOKEN_ID + i, HOUSE_PRICE / 2);
}
// totalCollateralValueAfterFirstDrop 1500e18
uint256 totalCollateralValueAfterFirstDrop = lendingPool.getUserCollateralValue(borrower);
console2.log("totalCollateralValueAfterFirstDrop", totalCollateralValueAfterFirstDrop);
//! totalCollaterValue is now the same as the borrow amount if it declines further, this will create bad debt
// the protocol needs to liquidate the borrower fast to get the collateral
lendingPool.initiateLiquidation(borrower);
assertEq(lendingPool.isUnderLiquidation(borrower), true);
// provide the necessary crvUSD to repay the debt + some extra to cover the interest
// This should be the collateral value from the deposited NFTs but there is no function to get that
// so we just provide the necessary amount directly
// The protocol needs to provide the amount to cover the debt + the interest which
crvusd.mint(address(stabilityPool), 1600e18);
// Liquidation of the borrower fails because the grace period is not expired
vm.expectRevert(ILendingPool.GracePeriodNotExpired.selector);
stabilityPool.liquidateBorrower(borrower);
// House prices drop further
for (uint256 i = 0; i < NFT_COUNT; i++) {
raacHousePrices.setHousePrice(TOKEN_ID + i, HOUSE_PRICE / 4);
}
// totalCollateralValueAfterSecondDrop 750e18
uint256 totalCollateralValueAfterSecondDrop = lendingPool.getUserCollateralValue(borrower);
console2.log("totalCollateralValueAfterSecondDrop", totalCollateralValueAfterSecondDrop);
// The borrower has no incentive to repay the debt anymore because the collateral value is lower than the borrowed amount
// So it's creating bad debt which the protocol needs to cover
assertLt(totalCollateralValueAfterSecondDrop, 1500e18);
}
function _setupInitialBalancesAndAllowances() internal {
// Mint crvUSD to users
crvusd.mint(user1, INITIAL_BALANCE);
crvusd.mint(user2, INITIAL_BALANCE);
crvusd.mint(user3, INITIAL_BALANCE);
// Setup approvals for users
vm.startPrank(user1);
crvusd.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(INITIAL_BALANCE);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
vm.startPrank(user2);
crvusd.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(INITIAL_BALANCE);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
vm.startPrank(user3);
crvusd.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(INITIAL_BALANCE);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
}
function _printReserveState() internal view {
(
address reserveRTokenAddr,
address reserveAssetAddr,
address reserveDebtTokenAddr,
uint256 totalLiquidity,
uint256 totalUsage,
uint128 liquidityIndex,
uint128 usageIndex,
uint40 lastUpdateTimestamp
) = lendingPool.reserve();
console2.log("Reserve Data");
console2.log("totalLiquidity", totalLiquidity);
console2.log("totalUsage", totalUsage);
console2.log("liquidityIndex", liquidityIndex);
console2.log("usageIndex", usageIndex);
console2.log("lastUpdateTimestamp", lastUpdateTimestamp);
console2.log("================================================");
}
function _printRateData() internal view {
(
uint256 currentLiquidityRate,
uint256 currentUsageRate,
uint256 primeRate,
uint256 baseRate,
uint256 optimalRate,
uint256 maxRate,
uint256 optimalUtilizationRate,
uint256 protocolFeeRate
) = lendingPool.rateData();
console2.log("Rate Data");
console2.log("currentLiquidityRate", currentLiquidityRate);
console2.log("currentUsageRate", currentUsageRate);
console2.log("primeRate", primeRate);
console2.log("baseRate", baseRate);
console2.log("optimalRate", optimalRate);
console2.log("maxRate", maxRate);
console2.log("optimalUtilizationRate", optimalUtilizationRate);
console2.log("protocolFeeRate", protocolFeeRate);
console2.log("================================================");
}
}

Impact

  • Increased losses for the protocol as underwater positions cannot be liquidated promptly

  • Strategic defaulting by borrowers when their collateral value falls significantly below their debt during the grace period

  • Greater risk for the Stability Pool, which must absorb larger losses

  • Potential protocol insolvency in cases of sharp market downturns

Tools Used

  • Manual Review

  • Foundry

Recommendations

  1. Dynamic Grace Period => When the liquidation gets initialized set the grace period based on the current health factor of the borrower. Lower health factor => lower grace period for that specific borrower (userAddress => gracePeriod mapping) .

  2. Implement some kind of Emergency Liquidation Threshold. If the health factor drops below the EMERGENCY_THRESHOLD set a state to immediately liquidate the borrower to access the collateral:

function initiateLiquidation(address userAddress) external {
uint256 healthFactor = calculateHealthFactor(userAddress);
// No grace period if severely underwater
if (healthFactor < EMERGENCY_THRESHOLD) {
isUnderLiquidation[userAddress] = true;
canLiquidateImmediately[userAddress] = true;
} else {
// Normal grace period logic
}
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Appeal created

mill1995 Submitter
4 months ago
inallhonesty Lead Judge
4 months ago
inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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