Core Contracts

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

StabilityPool::liquidateBorrower does not check if a loan is healthy before liquidation.

Summary

The liquidateBorrower() function in StabilityPool will liquidate a position even if it has become healthy.

Vulnerability Details

  1. Borrowers are allowed to call repay() for partial repayment to avoid liquidation.

  2. However, the closeLiquidation() function can only be called when nearly all debts has been repaid, even if their loan is healthy.

  3. The liquidateBorrower() function liquidates the borrower despite their loan being healthy.

  4. They also do not refund the partial repayments made by the user.

Impact

Borrowers with healthy loans can be liquidated if they did not call closeLiquidation() after partial repayment that keeps their loan healthy.

Tools Used

Manual Review

PoC

Foundry test is as follows:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import {IERC20, ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "../../contracts/libraries/math/PercentageMath.sol";
import "../../contracts/libraries/math/WadRayMath.sol";
import {BaseSetup} from "./BaseSetup.t.sol";
import "../../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
contract LiquidationTest is BaseSetup {
using WadRayMath for uint256;
using PercentageMath for uint256;
using SafeCast for uint256;
using SafeERC20 for IERC20;
IERC20 mainnetUSDC = IERC20(address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48));
address mainnetUsdcCurveUSDVault = address(0x7cA00559B978CFde81297849be6151d3ccB408A9);
address curveUSDWhale = address(0x4e59541306910aD6dC1daC0AC9dFB29bD9F15c67);
address newUser = makeAddr("new user");
address newUser2 = makeAddr("new user2");
address newUser3 = makeAddr("new user3");
address newUser4 = makeAddr("new user4");
address bob = makeAddr("bob");
function setUp() public override {
string memory MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL");
uint256 mainnetFork = vm.createFork(MAINNET_RPC_URL, 19614507);
vm.selectFork(mainnetFork);
super.setUp();
vm.startPrank(owner);
lendingPool.setParameter(ILendingPool.OwnerParameter.LiquidationThreshold, 8000); // 80%
lendingPool.setParameter(ILendingPool.OwnerParameter.HealthFactorLiquidationThreshold, 1e18); // 1.0e18
lendingPool.setParameter(ILendingPool.OwnerParameter.LiquidationGracePeriod, 3 days);
lendingPool.setParameter(ILendingPool.OwnerParameter.LiquidityBufferRatio, 2000); // 20%
lendingPool.setParameter(ILendingPool.OwnerParameter.WithdrawalStatus, 0); // Allow withdrawals
lendingPool.setParameter(ILendingPool.OwnerParameter.CanPaybackDebt, 1); // Enable payback
// house is 1000 with 18 decimals, same as crvUSD
uint256 housePrice = 1_000_000e18;
housePrices.setHousePrice(1, housePrice);
housePrices.setHousePrice(2, housePrice);
housePrices.setHousePrice(3, housePrice);
housePrices.setHousePrice(4, housePrice);
housePrices.setHousePrice(5, housePrice);
housePrices.setHousePrice(6, housePrice);
housePrices.setHousePrice(7, housePrice);
housePrices.setHousePrice(8, housePrice);
vm.stopPrank();
vm.startPrank(curveUSDWhale);
crvUSD.transfer(newUser, 15_000_000e18);
crvUSD.transfer(newUser2, 6_000_000e18); // newUser2 has 1M crvUSD
crvUSD.transfer(newUser3, 1_000_000e18);
crvUSD.transfer(newUser4, 5_000_000e18);
crvUSD.transfer(address(stabilityPool), 10_000_000e18);
crvUSD.transfer(bob, 3_000_000e18);
vm.stopPrank();
}
function makeLiquidityIndexNotRay() public {
// we will look at user3 and user4 difference
vm.startPrank(newUser);
//deposit some crvTokens as well
crvUSD.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(15_000_000e18);
vm.stopPrank();
vm.startPrank(newUser2);
crvUSD.approve(address(raacNFT), type(uint256).max);
raacNFT.mint(1, 1_000_000e18);
raacNFT.mint(2, 1_000_000e18);
raacNFT.mint(3, 1_000_000e18);
raacNFT.mint(4, 1_000_000e18);
raacNFT.mint(5, 1_000_000e18);
raacNFT.approve(address(lendingPool), 1);
raacNFT.approve(address(lendingPool), 2);
raacNFT.approve(address(lendingPool), 3);
raacNFT.approve(address(lendingPool), 4);
raacNFT.approve(address(lendingPool), 5);
lendingPool.depositNFT(1);
lendingPool.depositNFT(2);
lendingPool.depositNFT(3);
lendingPool.depositNFT(4);
lendingPool.depositNFT(5);
lendingPool.borrow(6_250_000e18);
assertEq(lendingPool.getNormalizedIncome(), 1e27);
vm.stopPrank();
// Here Liquidity Index is 1e27
vm.startPrank(newUser3);
crvUSD.approve(address(lendingPool), 10_000e18);
lendingPool.deposit(10_000e18);
vm.stopPrank();
console.log("newUser2 health before 1 year: %e", health1); // 8e17
skip(365 days);
lendingPool.updateState();
}
function test_CanStillBeLiquidatedAfterKeepingLoanHealthy() public {
// Step 1. make Liquidity Index and Usage Index greater than 1 Ray to simulate
// normal conditions
makeLiquidityIndexNotRay();
assertTrue(lendingPool.getNormalizedDebt()>1e27);
assertTrue(lendingPool.getNormalizedIncome()>1e27);
// Step 2. Let's have a Depositor, bob with 3Me18 crvTokens
assertEq(crvUSD.balanceOf(bob),3_000_000e18);
// Step 3. Bob mints nft id 8 for 1m
vm.startPrank(bob);
crvUSD.approve(address(raacNFT),type(uint256).max);
raacNFT.mint(8,1_000_000e18);
assertEq(raacNFT.ownerOf(8),bob);
// Step 4. bob deposits NFT
raacNFT.approve(address(lendingPool), 8);
lendingPool.depositNFT(8);
// Step 5. bob borrows enough to trigger liquidation (for simplicity)
uint256 initialBorrowAmount = 1_250_000e18;
lendingPool.borrow(initialBorrowAmount);
vm.stopPrank();
// Step 6. anyone calls initiateLiquidation
lendingPool.initiateLiquidation(bob);
// Step 7. Bob sees that he is underliquidation
// He decides to repay 1M out of 1.25M Loan
// To keep his loan healthy before liquidation
vm.startPrank(bob);
crvUSD.approve(address(lendingPool),type(uint256).max);
lendingPool.repay(1_000_000e18); //
// bob's health factor gte 1e18, so its healthy
assertTrue(lendingPool.calculateHealthFactor(bob) > 1e18);
//But Bob is still underliquidation
bool bobIsUnderLiquidation = lendingPool.isUnderLiquidation(bob);
assertTrue(bobIsUnderLiquidation);
vm.stopPrank();
// Step 8. Manager/owner decides to liquidate bob
skip(3 days + 1);
vm.startPrank(owner);
stabilityPool.liquidateBorrower(bob);
//Assert Bob's raacNFT has been transferred to Stability POol
assertEq(raacNFT.ownerOf(8),address(stabilityPool));
}
}

With this BaseSetup:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
// OpenZeppelin Imports
// import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
// import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
// Primitives Import
import {RAACHousePrices} from "../../contracts/core/primitives/RAACHousePrices.sol";
// Token Imports
import {DebtToken} from "../../contracts/core/tokens/DebtToken.sol";
import {DEToken} from "../../contracts/core/tokens/DEToken.sol";
import {IndexToken} from "../../contracts/core/tokens/IndexToken.sol";
import {LPToken} from "../../contracts/core/tokens/LPToken.sol";
import {RAACNFT} from "../../contracts/core/tokens/RAACNFT.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {RToken} from "../../contracts/core/tokens/RToken.sol";
import {veRAACToken} from "../../contracts/core/tokens/veRAACToken.sol";
// Collector Imports
import {Treasury} from "../../contracts/core/collectors/Treasury.sol";
import {FeeCollector} from "../../contracts/core/collectors/FeeCollector.sol";
// Governance Imports
import {Governance} from "../../contracts/core/governance/proposals/Governance.sol";
import {TimelockController} from "../../contracts/core/governance/proposals/TimelockController.sol";
// Gauge Imports
import {BaseGauge} from "../../contracts/core/governance/gauges/BaseGauge.sol";
import {GaugeController} from "../../contracts/core/governance/gauges/GaugeController.sol";
import {IGaugeController} from "../../contracts/interfaces/core/governance/gauges/IGaugeController.sol";
import {RAACGauge} from "../../contracts/core/governance/gauges/RAACGauge.sol";
import {RWAGauge} from "../../contracts/core/governance/gauges/RWAGauge.sol";
// Boost Import
import {BoostController} from "../../contracts/core/governance/boost/BoostController.sol";
// Minter Imports
import {RAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {RAACReleaseOrchestrator} from "../../contracts/core/minters/RAACReleaseOrchestrator/RAACReleaseOrchestrator.sol";
// Oracle Imports
import {BaseChainlinkFunctionsOracle} from "../../contracts/core/oracles/BaseChainlinkFunctionsOracle.sol";
import {RAACHousePriceOracle} from "../../contracts/core/oracles/RAACHousePriceOracle.sol";
import {RAACPrimeRateOracle} from "../../contracts/core/oracles/RAACPrimeRateOracle.sol";
// Pool Imports
import {LendingPool} from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import {StabilityPool} from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {MarketCreator} from "../../contracts/core/pools/StabilityPool/MarketCreator.sol";
import {NFTLiquidator} from "../../contracts/core/pools/StabilityPool/NFTLiquidator.sol";
contract StakingToken is ERC20 {
constructor() ERC20("ST","ST"){
}
function mint(address account, uint256 value) public {
_mint(account, value);
}
function burn(address account, uint256 value) public {
_burn(account, value);
}
}
contract BaseSetup is Test {
IERC20 baseUSDC = IERC20(address(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913));
address baseUSDCWhale = address(0x20FE51A9229EEf2cF8Ad9E89d91CAb9312cF3b7A);
// Collectors
Treasury treasury;
Treasury repairFund;
FeeCollector feeCollector;
// Tokens
IERC20 crvUSD = IERC20(address(0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E)); // main
IERC20 basecrvUSD = IERC20(address(0x417Ac0e078398C154EdFadD9Ef675d30Be60Af93)); // base
RAACToken raacToken;
veRAACToken veRaacToken;
RAACNFT raacNFT;
RToken rToken;
DebtToken debtToken;
DEToken deToken;
address mainnetChainlinkRouter = address(0x65Dcc24F8ff9e51F10DCc7Ed1e4e2A61e6E14bd6);
//m Orchestatro
RAACReleaseOrchestrator raacReleaseOrchestrator;
RAACHousePrices housePrices;
LendingPool lendingPool;
StabilityPool stabilityPool;
RAACMinter minter;
// Governance
Governance public governance;
TimelockController public timelockController;
BoostController public boostController;
GaugeController public gaugeController;
RAACGauge public raacGauge;
RWAGauge public rwaGauge;
address owner;
address user1;
address user2;
address user3;
StakingToken stakingToken;
function setUp() public virtual {
stakingToken = new StakingToken();
owner = makeAddr("owner");
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
vm.startPrank(owner);
// collectors
//
// Setup Tokens
raacToken = new RAACToken(owner, 100, 50);
veRaacToken = new veRAACToken(address(raacToken));
// veRAACToken
// RAACNFT
// DeToken
// Setup contracts
raacReleaseOrchestrator = new RAACReleaseOrchestrator(address(raacToken));
housePrices = new RAACHousePrices(owner);
housePrices.setOracle(owner);
raacNFT = new RAACNFT(address(crvUSD), address(housePrices), owner);
rToken = new RToken("RToken", "RT", owner, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", owner);
lendingPool = new LendingPool(
address(crvUSD), address(rToken), address(debtToken), address(raacNFT), address(housePrices), 0.1e27
);
console.log("lendingPool:", address(lendingPool));
treasury = new Treasury(owner);
repairFund = new Treasury(owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
stabilityPool = new StabilityPool(owner);
minter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner); // should be owner
feeCollector =
new FeeCollector(address(raacToken), address(veRaacToken), address(treasury), address(repairFund), owner);
raacToken.setFeeCollector(address(feeCollector));
raacToken.manageWhitelist(address(feeCollector), true);
raacToken.manageWhitelist(address(veRaacToken), true);
raacToken.manageWhitelist(owner, true);
raacToken.setMinter(address(owner));
raacToken.mint(user2, 1000e18);
raacToken.mint(user3, 1000e18);
feeCollector.grantRole(feeCollector.FEE_MANAGER_ROLE(), owner);
feeCollector.grantRole(feeCollector.EMERGENCY_ROLE(), owner);
feeCollector.grantRole(feeCollector.DISTRIBUTOR_ROLE(), owner);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
//
raacToken.setMinter(address(minter));
raacToken.transferOwnership(address(minter));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(minter),
address(crvUSD),
address(lendingPool)
);
lendingPool.setStabilityPool(address(stabilityPool));
// Governance Components
// Timelock controller
address[] memory proposers = new address[](1);
proposers[0] = owner;
address[] memory executors = new address[](1);
executors[0] = owner;
timelockController = new TimelockController(2 days, proposers, executors, owner);
// Governance
governance = new Governance(address(veRaacToken), address(timelockController));
timelockController.grantRole(timelockController.PROPOSER_ROLE(), address(governance));
timelockController.grantRole(timelockController.EXECUTOR_ROLE(), address(governance));
timelockController.grantRole(timelockController.CANCELLER_ROLE(), address(governance));
//Gauge
gaugeController = new GaugeController(address(veRaacToken));
boostController = new BoostController(address(veRaacToken));
rwaGauge = new RWAGauge(address(raacToken), address(stakingToken), address(gaugeController));
raacGauge = new RAACGauge(address(raacToken), address(stakingToken), address(gaugeController));
gaugeController.addGauge(address(rwaGauge),IGaugeController.GaugeType.RWA , 0);
gaugeController.addGauge(address(raacGauge),IGaugeController.GaugeType.RAAC , 0);
rwaGauge.grantRole(rwaGauge.CONTROLLER_ROLE(),owner);
raacGauge.grantRole(rwaGauge.CONTROLLER_ROLE(),owner);
vm.stopPrank();
}
}

Recommendations

function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
+ uint256 healthFactor = lendingPool.calculateHealthFactor(userAddress);
+ if (healthFactor >= lendingPool.liquidationThreshold()) revert LoanIsHealthy();
// Get the user's debt from the LendingPool.
uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
if (userDebt == 0) revert InvalidAmount();
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();
// Approve the LendingPool to transfer the debt amount
bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
if (!approveSuccess) revert ApprovalFailed();
// Update lending pool state before liquidation
lendingPool.updateState();
// Call finalizeLiquidation on LendingPool
lendingPool.finalizeLiquidation(userAddress);
emit BorrowerLiquidated(userAddress, scaledUserDebt);
}
Updates

Lead Judging Commences

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