Core Contracts

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

closeLiquidation within LendingPool does not allow partial repayments, which can cause massive losses to users within edge case

Description

While not accepting partial repayments within LendingPool::closeLiquidation is certainly a design choice, it also causes an edge case in which a user who is subject into liquidation can still deposit additional NFTs into the protocol, which would then be liquidated as well, causing an unjustified liquidation on an NFT value way over anything justifiable.

Vulnerable Code & Details

LendingPool::closeLiquidation:

function closeLiquidation() external nonReentrant whenNotPaused {
address userAddress = msg.sender;
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
ReserveLibrary.updateReserveState(reserve, rateData);
if (block.timestamp > liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodExpired();
}
UserData storage user = userData[userAddress];
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
if (userDebt > DUST_THRESHOLD) revert DebtNotZero();
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
emit LiquidationClosed(userAddress);
}

LendingPool::depositNFT:

function depositNFT(uint256 tokenId) external nonReentrant whenNotPaused {
ReserveLibrary.updateReserveState(reserve, rateData);
if (raacNFT.ownerOf(tokenId) != msg.sender) revert NotOwnerOfNFT();
UserData storage user = userData[msg.sender];
if (user.depositedNFTs[tokenId]) revert NFTAlreadyDeposited();
user.nftTokenIds.push(tokenId);
user.depositedNFTs[tokenId] = true;
raacNFT.safeTransferFrom(msg.sender, address(this), tokenId);
emit NFTDeposited(msg.sender, tokenId);
}

After looking at the code above consider the following scenario:

User Bob holds an NFT within RAAC worth 50k USD and loaned 40k USD against it within the protocol in anticipation of it's value increasing over time.
Now RAAC decides to drop some new Condos on the market for an early investment price of only 60k USD each, so bob decides to use his life savings to buy 4 of those units for this amazing deal, after all, he can deposit them back into the contract and borrow against it.
Now right before Bob deposits his newly bought assets RAACs price oracle updated the price on Bobs previous unit to 40k USD and initiates liquidation of Bob, since he is now undercollateralized.

Bob though deposits unknowingly his newly acquired NFTs into the contract to borrow against them only to realize that he is currently being liquidated. Having spent his remaining savings on those condos, Bob now does not have sufficient money to repay the old debt and will be liquidated with a Health Factor way above 1e18, losing all his investment.

PoC

Since the PoC is a foundry test I have added a Makefile at the end of this report to simplify installation for your convenience. Otherwise if console commands would be prefered:

First run: npm install --save-dev @nomicfoundation/hardhat-foundry

Second add: require("@nomicfoundation/hardhat-foundry"); on top of the Hardhat.Config file in the projects root directory.

Third run: npx hardhat init-foundry

And lastly, you will encounter one of the mock contracts throwing an error during compilation, this error can be circumvented by commenting out the code in entirety (ReserveLibraryMocks.sol).

And the test should be good to go:

To run the following PoC please apply a "fix" into StabilityPool as follows:

StabilityPool::liquidateBorrower

/// *** SNIP *** ///
+bool approveSuccess = crvUSDToken.approve(address(lendingPool), type(uint256).max);
-bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt;
/// *** SNIP *** ///

As mentioned in a previous report, this approval issue makes liquidating users impossible.

./test/invariant/foundry/PoC.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Test, console} from "forge-std/Test.sol";
import {StabilityPool} from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {LendingPool} from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import {CrvUSDToken} from "../../contracts/mocks/core/tokens/crvUSDToken.sol";
import {RAACHousePrices} from "../../contracts/core/oracles/RAACHousePriceOracle.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 {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {RAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
contract PoC is Test {
StabilityPool public stabilityPool;
LendingPool public lendingPool;
CrvUSDToken public crvusd;
RAACHousePrices public raacHousePrices;
RAACNFT public raacNFT;
RToken public rToken;
DebtToken public debtToken;
DEToken public deToken;
RAACToken public raacToken;
RAACMinter public raacMinter;
address owner;
address oracle;
address user1;
address user2;
address user3;
uint256 constant STARTING_TIME = 1641070800;
uint256 public currentBlockTimestamp;
uint256 constant WAD = 1e18;
uint256 constant RAY = 1e27;
function setUp() public {
vm.warp(STARTING_TIME);
currentBlockTimestamp = block.timestamp;
owner = address(this);
oracle = makeAddr("oracle");
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
uint256 initialPrimeRate = 0.1e27;
raacHousePrices = new RAACHousePrices(owner);
vm.prank(owner);
raacHousePrices.setOracle(oracle);
crvusd = new CrvUSDToken(owner);
raacNFT = new RAACNFT(address(crvusd), address(raacHousePrices), owner);
rToken = new RToken("RToken", "RToken", owner, address(crvusd));
debtToken = new DebtToken("DebtToken", "DT", owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
vm.prank(owner);
crvusd.setMinter(owner);
vm.prank(owner);
lendingPool = new LendingPool(
address(crvusd),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
initialPrimeRate
);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
stabilityPool = new StabilityPool(address(owner));
deToken.setStabilityPool(address(stabilityPool));
raacToken = new RAACToken(owner, 0, 0);
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner);
stabilityPool.initialize(address(rToken), address(deToken), address(raacToken), address(raacMinter), address(crvusd), address(lendingPool));
vm.prank(owner);
raacToken.setMinter(address(raacMinter));
lendingPool.setStabilityPool(address(stabilityPool));
crvusd.mint(user1, type(uint128).max);
crvusd.mint(user2, type(uint128).max);
crvusd.mint(user3, type(uint128).max);
}
function test_userLosingAllNftsWithWrongTiming() public {
/// Providing Liquidity to LendingPool and StabilityPool
crvusd.mint(address(stabilityPool), 100e18);
vm.startPrank(user3);
crvusd.approve(address(lendingPool), 10e18);
lendingPool.deposit(10e18);
vm.stopPrank();
/// Bob purchases his 1. Nft, deposits it and takes a loan
vm.prank(oracle);
raacHousePrices.setHousePrice(1, 5e18);
vm.startPrank(user1);
crvusd.approve(address(raacNFT), type(uint256).max);
raacNFT.mint(1, 5e18);
raacNFT.approve(address(lendingPool), 1);
lendingPool.depositNFT(1);
lendingPool.borrow(4e18);
vm.stopPrank();
/// The price gets adjusted downwards and liquidation will be initiated
vm.prank(oracle);
raacHousePrices.setHousePrice(1, 4e18);
vm.prank(user2);
lendingPool.initiateLiquidation(user1);
/// Bob purchases further NFTs, deposits them and loses them all without anything he can do about it
vm.startPrank(oracle);
raacHousePrices.setHousePrice(2, 5e18);
raacHousePrices.setHousePrice(3, 5e18);
raacHousePrices.setHousePrice(4, 5e18);
raacHousePrices.setHousePrice(5, 5e18);
vm.stopPrank();
vm.startPrank(user1);
raacNFT.mint(2, 5e18);
raacNFT.approve(address(lendingPool), 2);
lendingPool.depositNFT(2);
raacNFT.mint(3, 5e18);
raacNFT.approve(address(lendingPool), 3);
lendingPool.depositNFT(3);
raacNFT.mint(4, 5e18);
raacNFT.approve(address(lendingPool), 4);
lendingPool.depositNFT(4);
raacNFT.mint(5, 5e18);
raacNFT.approve(address(lendingPool), 5);
lendingPool.depositNFT(5);
// Just for good measure bob tries to close the liquidation, knowing he should be very much overcolleteralized but it doesnt work.
vm.expectRevert();
lendingPool.closeLiquidation();
vm.stopPrank();
/// The Liquidation will be executed, and bob loses all his NFTs
uint256 blockTime = block.timestamp;
vm.warp(blockTime + 4 * 1 days);
console.log("User Health Factor at time of Liquidation: ", lendingPool.calculateHealthFactor(user1) / 1e18);
assert(lendingPool.calculateHealthFactor(user1) > 1e18);
vm.prank(owner);
stabilityPool.liquidateBorrower(user1);
console.log("User Collateral Value after liquidation: ", debtToken.balanceOf(user1));
assertEq(0, lendingPool.getUserCollateralValue(user1));
}

After running it via forge test --mt test_userLosingAllNftsWithWrongTiming -vv we can see the following output:

Ran 1 test for test/invariant/Unit.t.sol:Unit
[PASS] test_userLosingAllNftsWithWrongTiming() (gas: 2051744)
Logs:
User Health Factor at time of Liquidation: 4
User Collateral Value after liquidation: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 29.19ms (11.84ms CPU time)
Ran 1 test suite in 53.44ms (29.19ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

The output showcasing clearly, that the user lost NFT IDs 2-5 too, not only ID 1. Therefor the user lost in this scenario 20e18 tokens on top of the intended liquidation.

Impact

Since this issue can only arise under certain pre-conditions and has a very limited timeframe to occur the likelihood would be rated as a low - medium.
Losing NFT value beyond the intend of liquidation though, is a severe financial harm to the user, therefor the impact is to be rated high.

As result i conclude a severity of High in total is justified, simply because the impact can be devastating.

Tools Used

Manual Review & Foundry

Recommended Fix

Since other functions use already logic which makes usage of functions impossible during initiated liquidation, consider adding the same access control to deposit NFTs to avoid edge cases or rethink changing the closeLiquidation function into a health factor based functionality. Assuming the liquidation logic is intended the way it is I would though stick with access control as shown here:

+ error CanNotDepositNFTDuringLiquidation();
function depositNFT(uint256 tokenId) external nonReentrant whenNotPaused {
ReserveLibrary.updateReserveState(reserve, rateData);
+ if (isUnderLiquidation[userAddress]) revert CanNotDepositNFTDuringLiquidation();
if (raacNFT.ownerOf(tokenId) != msg.sender) revert NotOwnerOfNFT();
UserData storage user = userData[msg.sender];
if (user.depositedNFTs[tokenId]) revert NFTAlreadyDeposited();
user.nftTokenIds.push(tokenId);
user.depositedNFTs[tokenId] = true;
raacNFT.safeTransferFrom(msg.sender, address(this), tokenId);
emit NFTDeposited(msg.sender, tokenId);
}

Appendix

Copy the following import into your Hardhat.Config file in the projects root dir:
require("@nomicfoundation/hardhat-foundry");

Paste the following into a new file "Makefile" into the projects root directory:

.PHONY: install-foundry init-foundry all
install-foundry:
npm install --save-dev @nomicfoundation/hardhat-foundry
init-foundry: install-foundry
npx hardhat init-foundry
# Default target that runs everything in sequence
all: install-foundry init-foundry

And run make all

Updates

Lead Judging Commences

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

StabilityPool has no ability to liquidate large positions due to all-or-nothing design - partial liquidation not supported, risking protocol insolvency

Support

FAQs

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