Core Contracts

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

Liquidations are enabled when repayments are disabled, causing borrowers to lose funds without a chance to repay

Summary

Debt repaying can be temporary disabled by the owner of the LendingPool contract, however finalizing liquidations are not disabled during this period. As a result, users' positions can accumulate more borrow interest, go above the liquidation threshold, and be liquidated, while users aren't able to repay the debts.

Vulnerability Details

The owner of the LendingPool contract can pause different functions of the contract, including repayments:

function repay(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
_repay(amount, msg.sender);
}

As a result, when repayments are disabled, the StabilityPool can liquidate any position where a liquidation has been initialized and after the desired grace period, borrowers won't be able to protect against that by repaying their debts. Thus, borrowers will be forced to lose their collateral.

It's worth to mention that no new liquidations can be initiated, so this finding assumes that a Liquidation has been initialized before the contract owner pauses the operations.

However the finalizeLiquidationfunction doesn't have the whenNotPaused modifier applied, meaning that this can still be called by the StabilityPool to execute any valid liquidation:

function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {}

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

// 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_CanLiquidateWhileRepayIsDisabled() public {
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);
vm.stopPrank();
// initiate liquidation
lendingPool.initiateLiquidation(borrower);
assertEq(lendingPool.isUnderLiquidation(borrower), true);
// now the owner pauses the lending pool
lendingPool.pause();
assertEq(lendingPool.paused(), true);
// Borrower want to repay his debt but fails because the pool is paused
vm.expectRevert();
lendingPool.repay(HOUSE_PRICE);
// wait for grace period to end
vm.warp(lendingPool.liquidationStartTime(borrower) + lendingPool.liquidationGracePeriod() + 1 seconds);
// give the Stability Pool enough crvUSD to repay
crvusd.mint(address(stabilityPool), 200e18);
// liquidate borrower is successful
vm.expectEmit();
emit IStabilityPool.BorrowerLiquidated(borrower, HOUSE_PRICE);
stabilityPool.liquidateBorrower(borrower);
}
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

This would unfairly prevent Borrowers from making their repayments while still allowing the StabilityPool to liquidate them.

Tools Used

  • Foundry

  • Manual Review

Recommendations

Consider disallowing liquidations when repayments are disabled by adding the whenNotPaused modifier to the finalizeLiquidation function. Alternatively, consider never disallowing repayments so that users could maintain their positions in a healthy risk range anytime.

Updates

Lead Judging Commences

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

Unfair Liquidation As Repayment / closeLiquidation Paused While Liquidations Enabled

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

Unfair Liquidation As Repayment / closeLiquidation Paused While Liquidations Enabled

Support

FAQs

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