Core Contracts

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

Critical accounting error in DebtToken totalSupply calculation

Summary

The DebtToken contract's total supply incorrectly decreases over time instead of reflecting the actual accumulated debt with interest. This error propagates through the protocol's risk parameters as the LendingPool uses this total supply to calculate utilization rates and interest rates, leading to systemic underestimation of protocol risk.

Vulnerability Details

The error occurs in the DebtToken's totalSupply calculation:

function totalSupply() public view override returns (uint256) {
uint256 scaledSupply = super.totalSupply();
// @audit-issue incorrect math, should be rayMul
@> return scaledSupply.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
}

The function:

  1. Gets the scaled supply (which is already scaled down by the index at mint time)

  2. Incorrectly divides it again by the current index (getNormalizedDebt())

This double division causes:

  • When index = 1e27 (initial): totalSupply = scaledSupply / 1e27

  • When index increases to 3.15e27: totalSupply = scaledSupply / 3.15e27

The correct formula should multiply by the index since the stored values are already scaled down.

PoC

  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 IncorrectTotalSupply.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 "@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 IncorrectTotalSupplyTest 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(1000e18);
}
function test_totalSupply_inDebtToken_decreasing() public {
_depositCrvUsdIntoLendingPoolForAllUsers(100e18); // total 300 tokens
// print totalSupply from debtToken
uint256 index = ILendingPool(lendingPool).getNormalizedDebt();
console.log("Initial index: %e", index);
console.log("totalSupply from debtToken: ", debtToken.totalSupply());
_depositNftBorrowFundsAndMakeUserLiquidatable(user1, 1, 300e18);
index = ILendingPool(lendingPool).getNormalizedDebt();
console.log("Index after borrow: %e", index);
console.log("totalSupply from debtToken after: %e", debtToken.totalSupply());
_advanceInTimeAndAccrueInterestInLendingPool(365 days);
index = ILendingPool(lendingPool).getNormalizedDebt();
console.log("Index after long time: %e", index);
console.log("totalSupply from debtToken after long time: %e", debtToken.totalSupply());
}
function test_incorrectRepaymentAmounts() public {
// Setup: Deposit and borrow
_depositCrvUsdIntoLendingPoolForAllUsers(100e18);
_depositNftBorrowFundsAndMakeUserLiquidatable(user1, 1, 100e18);
uint256 initialDebt = lendingPool.getUserDebt(user1);
console.log("Initial user debt: %e", initialDebt);
// Advance time to accrue interest
_advanceInTimeAndAccrueInterestInLendingPool(365 days);
// The reported total debt will be less than actual due to incorrect totalSupply
uint256 reportedTotalDebt = debtToken.totalSupply();
uint256 actualUserDebt = lendingPool.getUserDebt(user1);
console.log("Reported total debt: %e", reportedTotalDebt);
console.log("Actual user debt: %e", actualUserDebt);
// This proves the debt accounting is broken - total debt should never be less than individual debt
assertTrue(reportedTotalDebt < actualUserDebt, "Total debt should be incorrectly reported as less than user debt");
}
// 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);
}
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 _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), initialBalance);
crvUSD.approve(address(lendingPool), initialBalance);
rToken.approve(address(stabilityPool), initialBalance);
vm.stopPrank();
}
function _advanceInTime(uint256 time) internal {
vm.warp(block.timestamp + time);
vm.roll(block.number + 10000);
}
function _advanceInTimeAndAccrueInterestInLendingPool(uint256 time) internal {
uint256 usageIndex = lendingPool.getNormalizedDebt();
_advanceInTime(time);
lendingPool.updateState();
}
function _setupHousePrices(uint256 housePrice) internal {
vm.startPrank(owner);
raacHousePrices.setHousePrice(1, housePrice);
raacHousePrices.setHousePrice(2, housePrice);
raacHousePrices.setHousePrice(3, housePrice);
vm.stopPrank();
}
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 _depositNftBorrowFundsAndMakeUserLiquidatable(address user, uint256 tokenId, uint256 nftPrice) internal {
_setupHousePrice(nftPrice, tokenId);
vm.startPrank(user);
_mintAndDepositNftInLendingPool(tokenId, nftPrice);
_borrowCrvUsdTokenFromLendingPool(nftPrice/2);
vm.stopPrank();
// accrue interest
_advanceInTimeAndAccrueInterestInLendingPool(365 days);
// house price drops, // user is now liquidatable.
_setupHousePrice(nftPrice/2, tokenId);
}
}

Run: forge test --match-contract IncorrectTotalSupplyTest -vv

Result: Notice the totalSupply in the last test decreases and becomes < than the user debt.

Ran 2 tests for test/IncorrectTotalSupply.t.sol:IncorrectTotalSupplyTest
[PASS] test_incorrectRepaymentAmounts() (gas: 961972)
Logs:
Initial user debt: 6.9201532138923584469e19
Reported total debt: 2.6102288958838345696e19
Actual user debt: 9.5777041007489475433e19
[PASS] test_totalSupply_inDebtToken() (gas: 968288)
Logs:
Initial index: 1e27
totalSupply from debtToken: 0
Index after borrow: 1.77713021046952383858816964e27
totalSupply from debtToken after: 8.4405745350741345196e19
Index after long time: 3.158191784963454095960852667e27
totalSupply from debtToken after long time: 4.7495532321427961401e19
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 11.56ms (10.66ms CPU time)

Impact

  1. Protocol's total debt is increasingly underreported as interest accrues, masking the true extent of borrowed assets.

  2. LendingPool's utilization calculations are corrupted since they rely on totalSupply for determining total debt.

  3. Interest rate models and risk parameters malfunction due to artificially low debt reporting, potentially leading to protocol insolvency.

Tools Used

Manual Review & Foundry

Aave Debt Token:

Recommendations

Correct the totalSupply in calculation by multiplying instead of dividing by the index:

function totalSupply() public view override returns (uint256) {
uint256 scaledSupply = super.totalSupply();
- return scaledSupply.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
+ return scaledSupply.rayMul(ILendingPool(_reservePool).getNormalizedDebt());
}
Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

DebtToken::totalSupply incorrectly uses rayDiv instead of rayMul, severely under-reporting total debt and causing lending protocol accounting errors

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

DebtToken::totalSupply incorrectly uses rayDiv instead of rayMul, severely under-reporting total debt and causing lending protocol accounting errors

Support

FAQs

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