Core Contracts

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

Incorrect utilization rate forces protocol to issue maximum rewards indefinitely

Summary

The RAACMinter incorrectly measures total borrowed amount by using normalized debt (27 decimals) instead of debt token supply and compares this with total deposits (18 decimals). This artificially inflates the utilization rate, causing the protocol to continuously mint maximum rewards when it should not.

Vulnerability Details

The core issue lies in the getUtilizationRate() function of the RAACMinter contract:

/**
* @dev Calculates the current system utilization rate
@> * @return The utilization rate as a percentage (0-100)
*/
function getUtilizationRate() internal view returns (uint256) {
// @audit - returns value in 1e27 (RAY) precision
uint256 totalBorrowed = lendingPool.getNormalizedDebt();
// @audit - returns value in 1e18 precision
uint256 totalDeposits = stabilityPool.getTotalDeposits();
if (totalDeposits == 0) return 0;
return (totalBorrowed * 100) / totalDeposits;
}

The function has two critical flaws that combine into one root cause:

  1. It uses getNormalizedDebt() which returns a debt index-adjusted value instead of the actual total borrowed amount. The correct approach would be to use the debt token's total supply.

  2. The current implementation directly compares values of different precision-normalized debt in RAY (1e27) against total deposits in standard precision (1e18). This creates a 1e9 inflation factor in the calculation.

Notice that the getUtilizationRate()function is supposed to return a value in percentage(0-100). That said, the returned value will be much greater whenever there is any borrowed amount.

This inflated utilization rate then cascades through the emission rate calculations in the tick() function:

function tick() external nonReentrant whenNotPaused {
if (emissionUpdateInterval == 0 || block.timestamp >= lastEmissionUpdateTimestamp + emissionUpdateInterval) {
// @audit here emissionRate is incorrectly inflated due to the
// inflated getUtilizationRATE
@> updateEmissionRate();
}
uint256 currentBlock = block.number;
uint256 blocksSinceLastUpdate = currentBlock - lastUpdateBlock;
if (blocksSinceLastUpdate > 0) {
uint256 amountToMint = emissionRate * blocksSinceLastUpdate;
if (amountToMint > 0) {
excessTokens += amountToMint;
lastUpdateBlock = currentBlock;
// @audit-issue minting more tokens than necessary
@> raacToken.mint(address(stabilityPool), amountToMint);
emit RAACMinted(amountToMint);
}
}
}

Because the utilization rate is artificially high, the emission rate constantly increases through the updateEmissionRate()function until it reaches maximum levels.

This will force the protocol to keep issuing the maximum of rewards even when the utilization rate is low.

PoC

First, we have to fix the following bug submitted in other reports to reproduce this PoC.

In DebtToken.sol:

@@ -251,5 +251,5 @@ contract DebtToken {
function totalSupply() public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledSupply = super.totalSupply();
- return scaledSupply.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
+ // BUGFIX 1 - Incorrect calc with rayDiv. Should be rayMul.
+ return scaledSupply.rayMul(ILendingPool(_reservePool).getNormalizedDebt());
}

Let's setup the test:

  • 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

  • Create a file called RAACMinter.t.sol in the test folder

  • Paste the code below

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import "../contracts/core/governance/boost/BoostController.sol";
import "../contracts/libraries/math/WadRayMath.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 RAACMinterTest 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;
vm.label(user1, "USER1");
vm.label(user2, "USER2");
vm.label(user3, "USER3");
// initiate timestamp and block
vm.warp(1738798039); // 2025-02-05
vm.roll(100); // block
vm.startPrank(owner);
_deployAndSetupContracts();
vm.stopPrank();
_mintCrvUsdTokenToUsers(10000e18);
_depositCrvUsdIntoLendingPoolForAllUsers(100e18);
_depositRTokenIntoLendingPoolForAllUsers(100e18);
// here we increase the usageIndex(debt index)
// by accruing interest and borrowing funds.
_liquidateUserAndAccrueInterest(user1, 1);
_liquidateUserAndAccrueInterest(user3, 2);
_depositNftBorrowFunds(user1, 3, 500e18, false);
}
function test_getUtilizationRate_returnsIncorrectValue_leadingToPermanent_MaximumEmissionOfRewards() public {
// 1. Show the incorrect utilization rate calc and values.
// print lendingPool.getNormalizedDebt 27 decimals
console.log("NormalizeDebt: %e", lendingPool.getNormalizedDebt());
// print totalDeposits 18 decimals
console.log("TotalDeposits: %e", stabilityPool.getTotalDeposits());
// getUtilizationRate is expected to return the result in percentage (0-100)
console.log("getUtilizationRate: %d", _getUtilizationRate());
// result is 4.6118823e8. Exponentially greater than 100.
assertGt(_getUtilizationRate(), 100, "!100");
// 2. Show the inflated utilization rate
// leading to inflated emission of rewards.
for(uint256 i = 0; i < 20; i++) {
uint256 raacTotalSupplyBeforeEmission = raacToken.totalSupply();
uint256 emissionRateBefore = raacMinter.getEmissionRate();
_advanceInTime(1 days + 1 seconds);
raacMinter.tick();
// Forces the rewards emission to always be above the utilizationTarget.
// Impact: issuing maximum of rewards per day.
console.log("RAAC minted during one day: %e", raacToken.totalSupply() - raacTotalSupplyBeforeEmission);
}
}
// 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
);
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);
}
/**
* @dev Calculates the current system utilization rate
* @return The utilization rate as a percentage (0-100)
*/
function _getUtilizationRate() internal view returns (uint256) {
// decimals 1e27
uint256 totalBorrowed = lendingPool.getNormalizedDebt();
console.log("totalBorrowed: %e", totalBorrowed);
// decimals 1e18
uint256 totalDeposits = stabilityPool.getTotalDeposits();
if (totalDeposits == 0) return 0;
return (totalBorrowed * 100) / totalDeposits;
}
function _liquidateUserAndAccrueInterest(address user, uint256 tokenId) internal {
_depositNftBorrowFunds(user, tokenId, 600e18, true);
vm.startPrank(owner);
// liquidate user
lendingPool.initiateLiquidation(user);
// pass grace period
_advanceInTime(3 days);
lendingPool.updateState();
// 1 sec after updating the pool's state, we call liquidateBorrower.
_advanceInTime(1 seconds);
// user scaled debt
lendingPool.updateState();
// fund stability pool with crvUSD to cover the debt
deal(address(crvUSD), address(stabilityPool), lendingPool.getUserDebt(user));
stabilityPool.liquidateBorrower(user);
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 _depositRTokenIntoLendingPoolForAllUsers(uint256 initialDeposit) internal {
// iterate users array and deposit into lending pool
for (uint i = 0; i < users.length; i++) {
vm.prank(users[i]);
stabilityPool.deposit(initialDeposit);
}
}
function _mintCrvUsdTokenToUsers(uint256 initialBalance) internal {
for (uint i = 0; i < users.length; i++) {
_mintCrvUsdTokenToUser(initialBalance, users[i]);
}
}
function _mintCrvUsdTokenToUser(uint256 initialBalance, address user) internal {
vm.prank(owner);
crvUSD.mint(user, initialBalance);
vm.startPrank(user);
crvUSD.approve(address(raacNFT), type(uint256).max);
crvUSD.approve(address(lendingPool), type(uint256).max);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
}
function _advanceInTime(uint256 time) internal {
vm.warp(block.timestamp + time);
vm.roll(block.number + 7200);
}
function _advanceInTimeAndBlocks(uint256 time, uint256 blocks) internal {
vm.warp(block.timestamp + time);
vm.roll(block.number + blocks);
}
function _advanceInTimeAndAccrueInterestInLendingPool(uint256 time) internal {
uint256 usageIndex = lendingPool.getNormalizedDebt();
_advanceInTime(time);
lendingPool.updateState();
}
function _setupHousePrice(uint256 housePrice, uint256 newValue) internal {
vm.startPrank(owner);
raacHousePrices.setHousePrice(newValue, housePrice);
vm.stopPrank();
}
function _mintNFTwithTokenId(uint256 tokenId, uint256 housePrice) internal {
raacNFT.mint(tokenId, housePrice);
raacNFT.approve(address(lendingPool), tokenId);
}
function _mintAndDepositNftInLendingPool(uint256 tokenId, uint256 housePrice) internal {
_mintNFTwithTokenId(tokenId, housePrice);
lendingPool.depositNFT(tokenId);
}
function _borrowCrvUsdTokenFromLendingPool(uint256 amount) internal {
lendingPool.borrow(amount);
}
function _depositNftBorrowFunds(address user, uint256 tokenId, uint256 nftPrice, bool makeUserLiquidatable) internal {
_setupHousePrice(nftPrice, tokenId);
vm.startPrank(user);
_mintAndDepositNftInLendingPool(tokenId, nftPrice);
_borrowCrvUsdTokenFromLendingPool(nftPrice/2);
vm.stopPrank();
if (makeUserLiquidatable) {
// accrue interest
_advanceInTimeAndAccrueInterestInLendingPool(365 days);
// house price drops, user is now liquidatable.
_setupHousePrice(nftPrice/2, tokenId);
}
}
}

Run: test_getUtilizationRate_returnsIncorrectValue_leadingToPermanent_MaximumEmissionOfRewards()

Output: Inflated utilization rate, leading to inflated emission rate.

Ran 1 test for test/RAACMinter.t.sol:RAACMinterTest
[PASS] test_getUtilizationRate_returnsIncorrectValue_leadingToPermanent_MaximumEmissionOfRewards() (gas: 609810)
Logs:
NormalizeDebt: 3.1234054078898067473481334106e28
TotalDeposits: 1.305265665501601281484e21
totalBorrowed: 3.1234054078898067473481334106e28
@> getUtilizationRate: 2392926965 // completely inflated. should be 0-100
totalBorrowed: 3.1234054078898067473481334106e28
RAAC minted during one day: 1.1024999999999999856e21
RAAC minted during one day: 1.1576249999999999784e21
RAAC minted during one day: 1.2155062499999999712e21
RAAC minted during one day: 1.276281562499999964e21
RAAC minted during one day: 1.3400956406249999568e21
RAAC minted during one day: 1.4071004226562499496e21
RAAC minted during one day: 1.4774554437890624424e21
RAAC minted during one day: 1.5513282159785155584e21
RAAC minted during one day: 1.628894626777441332e21
RAAC minted during one day: 1.7103393581163133968e21
RAAC minted during one day: 1.7958563260221290616e21
RAAC minted during one day: 1.8856491423232355136e21
RAAC minted during one day: 1.9799315994393972864e21
RAAC minted during one day: 1.9999999999999999944e21
RAAC minted during one day: 1.9999999999999999944e21
RAAC minted during one day: 1.9999999999999999944e21
RAAC minted during one day: 1.9999999999999999944e21
RAAC minted during one day: 1.9999999999999999944e21
RAAC minted during one day: 1.9999999999999999944e21
RAAC minted during one day: 1.9999999999999999944e21
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.21ms (1.31ms CPU time)

Impact

  • The RAACMinter will mint at its maximum capacity regardless of the protocol's actual utilization.

  • When utilization drops below the utilization target, emissions continue at maximum instead of decreasing by the 5% adjustment factor.

  • Unsustainable token inflation.

Tools Used

Manual Review & Foundry

Recommendations

Modify the utilization rate calculation to use the correct debt measurement and handle decimal precision properly:

function getUtilizationRate() internal view returns (uint256) {
- uint256 totalBorrowed = lendingPool.getNormalizedDebt();
+ uint256 totalBorrowed = IERC20(lendingPool.debtToken()).totalSupply();
uint256 totalDeposits = stabilityPool.getTotalDeposits();
if (totalDeposits == 0) return 0;
return (totalBorrowed * 100) / totalDeposits;
}

Retesting:

Now apply the same logic above to the PoC's function _getUtilizationRate() so we can log the correct utilization rate.

Run the PoC again. Result: The utilization rate is correctly returned(a value between 0-100):

Ran 1 test for test/RAACMinter.t.sol:RAACMinterTest
[FAIL: !100: 19 <= 100] test_getUtilizationRate_returnsIncorrectValue_leadingToPermanent_MaximumEmissionOfRewards() (gas: 61844)
Logs:
NormalizeDebt: 3.1234054078898067473481334106e28
TotalDeposits: 1.305265665501601281484e21
totalBorrowed: 2.50000000000000000004e20
@> getUtilizationRate: 19
totalBorrowed: 2.50000000000000000004e20

Now comment the assertGt line so we can see the output from the loop using the correct utilization rate. Then rerun the PoC:

[PASS] test_getUtilizationRate_returnsIncorrectValue_leadingToPermanent_MaximumEmissionOfRewards() (gas: 670807)
Logs:
NormalizeDebt: 3.1234054078898067473481334106e28
TotalDeposits: 1.305265665501601281484e21
totalBorrowed: 2.50000000000000000004e20
getUtilizationRate: 19
RAAC minted during one day: 9.024999999999999984e20
RAAC minted during one day: 8.573749999999999992e20
RAAC minted during one day: 8.145062500000000032e20
RAAC minted during one day: 7.737809375000000088e20
RAAC minted during one day: 7.350918906250000152e20
RAAC minted during one day: 6.983372960937500184e20
RAAC minted during one day: 6.6342043128906252e20
RAAC minted during one day: 6.302494097246093976e20
RAAC minted during one day: 5.987369392383789288e20
RAAC minted during one day: 5.688000922764599856e20
RAAC minted during one day: 5.403600876626369928e20
RAAC minted during one day: 5.133420832795051464e20
RAAC minted during one day: 4.876749791155298952e20
RAAC minted during one day: 4.632912301597534008e20
RAAC minted during one day: 4.401266686517657376e20
RAAC minted during one day: 4.181203352191774536e20
RAAC minted during one day: 3.972143184582185856e20
RAAC minted during one day: 3.773536025353076592e20
RAAC minted during one day: 3.584859224085422784e20
RAAC minted during one day: 3.405616262881151688e20

RAAC emission is correct and properly decreased over time if the utilization ratio doesn't change.

Updates

Lead Judging Commences

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

RAACMinter::getUtilizationRate incorrectly mixes stability pool deposits with lending pool debt index instead of using proper lending pool metrics

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

RAACMinter::getUtilizationRate incorrectly mixes stability pool deposits with lending pool debt index instead of using proper lending pool metrics

Support

FAQs

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