Core Contracts

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

Liquidation fails if NFT House price drops to zero leaving the protocol with bad debt

Summary

The LendingPool contract's liquidation mechanism fails when an NFT's house price drops to zero, preventing the liquidation of undercollateralized positions. This creates a situation where bad debt cannot be cleared from the protocol, potentially leading to insolvency.

Vulnerability Details

In the getNFTPrice() function, there is a check that reverts if the price returned from the oracle is zero:

function getNFTPrice(uint256 tokenId) public view returns (uint256) {
(uint256 price, uint256 lastUpdateTimestamp) = priceOracle.getLatestPrice(tokenId);
@> if (price == 0) revert InvalidNFTPrice();
return price;
}

This function is called when a new liquidation process is initialized, the function call sequence is the following:

  1. initiateLiquidation() gets called

  2. initiateLiquidation() => calls calculateHealthFactor() to ge the current health factor for the liquidated user

  3. calculateHealthFactor() => calls getUserCollateralValue() to get the total collateral value of the liquidated user

  4. getUserCollateralValue() => calls getNFTPrice()

  5. getNFTPrice()will revert with InvalidNFTPrice error

The core issue is that the protocol assumes NFT prices / House prices will always be greater than zero, but this assumption doesn't hold in real-world scenarios where:

  • The underlying asset could become worthless (due to fire, flood ...)

  • Market conditions could drive prices to effectively zero

PoC

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

// 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";
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_LiquidationFailsWhenHousePriceDropsToZero() public {
address attacker = makeAddr("attacker");
crvusd.mint(attacker, HOUSE_PRICE);
assertEq(crvusd.balanceOf(attacker), HOUSE_PRICE);
// determine how much is in the lending pool available to borrow = 3000e18
uint256 availableToBorrow = crvusd.balanceOf(address(rToken));
assertEq(availableToBorrow, INITIAL_BALANCE * 3);
// mint nft to attacker for 100e18
vm.startPrank(attacker);
crvusd.approve(address(raacNFT), HOUSE_PRICE);
raacNFT.mint(TOKEN_ID, HOUSE_PRICE);
assertEq(raacNFT.balanceOf(attacker), 1);
// deposit NFT to the lending pool
raacNFT.approve(address(lendingPool), TOKEN_ID);
lendingPool.depositNFT(TOKEN_ID);
assertEq(raacNFT.balanceOf(address(lendingPool)), 1);
// Get the liquidation threshold from the protocol
uint256 liquidationThreshold = lendingPool.liquidationThreshold();
// Calculate the maximum allowed borrow amount based on the threshold
uint256 maxBorrowable = HOUSE_PRICE.percentMul(liquidationThreshold);
console2.log("maxBorrowable", maxBorrowable);
// Borrow the max amount allowed by the protocol
lendingPool.borrow(maxBorrowable);
assertEq(crvusd.balanceOf(attacker), maxBorrowable);
assertEq(crvusd.balanceOf(address(rToken)), availableToBorrow - maxBorrowable);
vm.stopPrank();
//! House price drops to 0 !
raacHousePrices.setHousePrice(TOKEN_ID, 0);
//! initiate liquidation => fails
vm.expectRevert(ILendingPool.InvalidNFTPrice.selector);
lendingPool.initiateLiquidation(attacker);
}
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();
}
}

Impact

  • Undercollateralized positions cannot be liquidated if the NFT price drops to zero

  • Bad debt accumulates in the protocol without any mechanism to clear it

  • Protocol's solvency is at risk as debt positions become unrecoverable

  • Other users' deposits are at risk due to the accumulation of bad debt

Tools Used

  • Foundry

  • Manual Review

Recommendations

  • Remove the zero price check and handle zero prices gracefully

  • Add emergency liquidation procedures for edge case to clear bad debt

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
inallhonesty Lead Judge 2 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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