Core Contracts

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

RAACMinter's utilization rate is incorrectly calculated

Summary

Incorrect calculation of RAACMinter's utilization rate will push its emission rate to maximum rate for most of the cases.

Vulnerability Details

Root Cause Analysis

The vulnerabilty resides in RAACMinter's getUtilizationRatefunction:

function getUtilizationRate() internal view returns (uint256) {
@> uint256 totalBorrowed = lendingPool.getNormalizedDebt(); // incorrect debt
uint256 totalDeposits = stabilityPool.getTotalDeposits();
if (totalDeposits == 0) return 0;
return (totalBorrowed * 100) / totalDeposits;
}

lendingPool.getNoramlizedDebt does not return total debt, but returns liquidity index:

function getNormalizedDebt() external view returns (uint256) {
return reserve.usageIndex;
}

Liquidity index is a ray, so it will have 27 decimals.

However, stabilityPool.getTotalDeposits returns RToken balance of StabilityPool. So it will have 18 decimals.

So unless total deposits is greater than 1B (i.e. 1e9) USD, utilization rate will always be greater than 100%

If we take a look into RAACMinter.calculateNewEmissionRatefunction:

function calculateNewEmissionRate() internal returns (uint256) {
@> uint256 utilizationRate = getUtilizationRate(); // @audit utilization rate is greater than 100
emit log_named_decimal_uint("utilizationRate", utilizationRate, 2);
uint256 adjustment = (emissionRate * adjustmentFactor) / 100;
@> if (utilizationRate > utilizationTarget) { // @audit utilization rate is greater than 70
uint256 increasedRate = emissionRate + adjustment;
uint256 maxRate = increasedRate > benchmarkRate ? increasedRate : benchmarkRate;
@> return maxRate < maxEmissionRate ? maxRate : maxEmissionRate; // @audit new rate will move towards max rate
} else if (utilizationRate < utilizationTarget) {
uint256 decreasedRate = emissionRate > adjustment ? emissionRate - adjustment : 0;
uint256 minRate = decreasedRate < benchmarkRate ? decreasedRate : benchmarkRate;
return minRate > minEmissionRate ? minRate : minEmissionRate;
}
return emissionRate;
}

Since utilizationRate > utilizationTarget, new rate will always move towards max rate.

According to POC test, the protocol will reach to max emission rate within 15 days. And emissionrate will stay there until deposit amount is greater than 1.42B ( = 1B / 0.7)

POC

Scenario

  • Stability pool has 100k RToken

  • Lending pool has 10k debt

  • 15 day passes

  • RAACMinter's emission rate reaches its maximum and stays there forever

How to run POC

pragma solidity ^0.8.19;
import "../lib/forge-std/src/Test.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 {RAACToken} from "../contracts/core/tokens/RAACToken.sol";
import {veRAACToken} from "../contracts/core/tokens/veRAACToken.sol";
import {RAACNFT} from "../contracts/core/tokens/RAACNFT.sol";
import {LendingPool} from "../contracts/core/pools/LendingPool/LendingPool.sol";
import {StabilityPool} from "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {RAACMinter} from "../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {crvUSDToken} from "../contracts/mocks/core/tokens/crvUSDToken.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract RAACHousePricesMock {
mapping(uint256 => uint256) public prices;
function getLatestPrice(uint256 tokenId) external view returns (uint256, uint256) {
return (prices[tokenId], block.timestamp);
}
function setTokenPrice(uint256 tokenId, uint256 price) external {
prices[tokenId] = price;
}
function tokenToHousePrice(uint256 tokenId) external view returns (uint256) {
return prices[tokenId];
}
}
contract RAACMinterTest is Test {
RToken rtoken;
DebtToken debtToken;
RAACToken raacToken;
DEToken deToken;
veRAACToken veToken;
RAACNFT raacNft;
RAACMinter raacMinter;
crvUSDToken asset;
LendingPool lendingPool;
StabilityPool stabilityPool;
RAACHousePricesMock housePrice;
address depositor = makeAddr("depositor");
address borrower = makeAddr("borrower");
address user = makeAddr("user");
uint256 userAssetAmount = 10_000e18;
uint256 tokenId = 1;
uint256 initialBurnTaxRate = 50;
uint256 initialSwapTaxRate = 100;
uint256 initialPrimeRate = 0.1e27;
function setUp() external {
vm.warp(1e9); // warp time stamp to avoid underflow in RAACMinter constructor
asset = new crvUSDToken(address(this));
housePrice = new RAACHousePricesMock();
debtToken = new DebtToken("DebtToken", "DTK", address(this));
rtoken = new RToken("RToken", "RTK", address(this), address(asset));
raacNft = new RAACNFT(address(asset), address(housePrice), address(this));
lendingPool = new LendingPool(
address(asset), address(rtoken), address(debtToken), address(raacNft), address(housePrice), 0.1e27
);
rtoken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken = new DEToken("DEToken", "DET", address(this), address(rtoken));
raacToken = new RAACToken(address(this), initialSwapTaxRate, initialBurnTaxRate);
stabilityPool = new StabilityPool(address(this));
stabilityPool.initialize(
address(rtoken), address(deToken), address(raacToken), address(this), address(asset), address(lendingPool)
);
lendingPool.setStabilityPool(address(stabilityPool));
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), address(this));
stabilityPool.setRAACMinter(address(raacMinter));
raacToken.setMinter(address(raacMinter));
veToken = new veRAACToken(address(raacToken));
raacToken.manageWhitelist(address(veToken), true);
deToken.setStabilityPool(address(stabilityPool));
// deposit amount is ten times of borrowed amount
uint256 depositorAmount = userAssetAmount * 10;
deal(address(asset), depositor, depositorAmount);
vm.startPrank(depositor);
asset.approve(address(lendingPool), depositorAmount);
lendingPool.deposit(depositorAmount);
uint256 rtokenBalance = rtoken.balanceOf(depositor);
rtoken.approve(address(stabilityPool), rtokenBalance);
stabilityPool.deposit(rtokenBalance);
vm.stopPrank();
}
function testEmissionRate() public {
// debt / deposit ratio is 1/10
_createDebt(userAssetAmount);
// update emission rate every day for 15 days
for (uint256 i; i < 16; i++) {
skip(1 days);
vm.roll(7200);
lendingPool.updateState();
raacMinter.updateEmissionRate();
}
// emission rate reached its maximum
assertEq(raacMinter.getEmissionRate(), raacMinter.maxEmissionRate());
// emission rate does not decrease
for (uint256 i = 16; i < 100; i++) {
skip(1 days);
vm.roll(7200);
lendingPool.updateState();
raacMinter.updateEmissionRate();
assertEq(raacMinter.getEmissionRate(), raacMinter.maxEmissionRate());
}
}
function _createDebt(uint256 amount) internal {
uint256 price = amount * 8 / 10;
deal(address(asset), borrower, price);
housePrice.setTokenPrice(tokenId, price);
vm.startPrank(borrower);
asset.approve(address(raacNft), price);
raacNft.mint(tokenId, price);
raacNft.approve(address(lendingPool), tokenId);
lendingPool.depositNFT(tokenId);
tokenId++;
lendingPool.borrow(amount);
vm.stopPrank();
}
}

Impact

RAACToken will be minted faster than expected.

Tools Used

Manual Review, Foundry

Recommendations

There are two values that represents total debt:

  • reserve.totalUsage in LendingPool

  • DebtToken.totalSupply

RAACMinter's utilization rate should use either one of the values.

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.