Core Contracts

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

Liquidation Cannot Be Closed Even With Healthy Position Due To Strict Debt Check

Summary

The closeLiquidation() function in the LendingPool contract requires users to repay their debt almost entirely (below a dust threshold of 1e6) to close a liquidation, rather than checking if their position has returned to a healthy state through the health factor. This prevents users from closing liquidations even when their position becomes adequately collateralized through partial repayment or additional collateral.

Vulnerability Details

The current implementation in closeLiquidation() uses a strict debt check:

function closeLiquidation() external nonReentrant whenNotPaused {
// ...
if (userDebt > DUST_THRESHOLD) revert DebtNotZero();
// ...
}

The function requires the user's debt to be less than DUST_THRESHOLD (1e6) to close the liquidation. However, the users health score is actually determined by the health factor:

function calculateHealthFactor(address userAddress) public view returns (uint256) {
uint256 collateralValue = getUserCollateralValue(userAddress);
uint256 userDebt = getUserDebt(userAddress);
if (userDebt < 1) return type(uint256).max;
uint256 collateralThreshold = collateralValue.percentMul(liquidationThreshold);
return (collateralThreshold * 1e18) / userDebt;
}

A position is considered healthy when its health factor is above healthFactorLiquidationThreshold (1e18). The current implementation ignores this key metric when allowing users to close liquidations.

Even when a borrower repays a significant value of his debt, he can not close the liquidation process.

Additional to that when a borrower adds more collateral to increase his health factor he still can not stop the liquidation process.

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";
import {IStabilityPool} from "../../contracts/interfaces/core/pools/StabilityPool/IStabilityPool.sol";
contract LendingPoolTest 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_CanNotCloseLiquidationAfterPartialRepayment() public {
// Setup borrower
address borrower = makeAddr("borrower");
crvusd.mint(borrower, HOUSE_PRICE);
assertEq(crvusd.balanceOf(borrower), HOUSE_PRICE);
// mint nft to borrower for 100e18
vm.startPrank(borrower);
crvusd.approve(address(raacNFT), HOUSE_PRICE);
raacNFT.mint(TOKEN_ID, HOUSE_PRICE);
assertEq(raacNFT.balanceOf(borrower), 1);
// deposit NFT to the lending pool
raacNFT.approve(address(lendingPool), TOKEN_ID);
lendingPool.depositNFT(TOKEN_ID);
assertEq(raacNFT.balanceOf(address(lendingPool)), 1);
// Borrow
lendingPool.borrow(HOUSE_PRICE);
assertEq(crvusd.balanceOf(borrower), HOUSE_PRICE);
// Skip some time to accrue interest
vm.warp(block.timestamp + 20 days);
// update state to get the real debt token balance
lendingPool.updateState();
uint256 debtTokenBalanceBefore = debtToken.balanceOf(borrower);
console2.log("test_RepaymentFlow: debtTokenBalanceBefore", debtTokenBalanceBefore);
uint256 healthFactorBefore = lendingPool.calculateHealthFactor(borrower);
// health factor is 0.78
console2.log("test_RepaymentFlow: healthFactorBefore", healthFactorBefore);
uint256 userDebt = lendingPool.getUserDebt(borrower);
console2.log("test_RepaymentFlow: userDebt", userDebt);
crvusd.mint(address(borrower), userDebt);
crvusd.approve(address(lendingPool), userDebt);
// borrower liquidation gets triggered
lendingPool.initiateLiquidation(borrower);
assertEq(lendingPool.isUnderLiquidation(borrower), true);
// Borrower repays half of his debt to increase his health factor
lendingPool.repay(userDebt / 2);
uint256 debtTokenBalanceAfter = debtToken.balanceOf(borrower);
console2.log("test_RepaymentFlow: debtTokenBalanceAfter", debtTokenBalanceAfter);
uint256 healthFactorAfter = lendingPool.calculateHealthFactor(borrower);
// health factor after is 1.57
console2.log("test_RepaymentFlow: healthFactorAfter", healthFactorAfter);
// close liquidation fails
vm.expectRevert(ILendingPool.DebtNotZero.selector);
lendingPool.closeLiquidation();
vm.stopPrank();
}
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

  • Positions may be unnecessarily liquidated even after becoming healthy

  • This creates unfair conditions for borrowers trying to recover their positions

  • The 3-day grace period becomes less effective since users must repay almost all debt rather than just reaching a healthy position

Tools Used

  • Manual Review

  • Foundry

Recommendations

Replace the debt threshold check with a health factor check in the closeLiquidation() function:

function closeLiquidation() external nonReentrant whenNotPaused {
address userAddress = msg.sender;
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
// update state
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();
+ uint256 healthFactor = calculateHealthFactor(userAddress);
+ if (healthFactor < healthFactorLiquidationThreshold) revert HealthFactorTooLow();
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
emit LiquidationClosed(userAddress);
}
Updates

Lead Judging Commences

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