Core Contracts

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

Incorrect Scaling in DebtToken's totalSupply Calculation

Summary

The DebtToken contract's totalSupply() function scales down instead of up. This leads to an underreported total debt in the protocol and could cause serious accounting issues.

Vulnerability Details

The vulnerability exists in the totalSupply() function:

function totalSupply() public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledSupply = super.totalSupply();
// Incorrect: Uses rayDiv instead of rayMul
return scaledSupply.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
}

The function incorrectly uses rayDiv when it should use rayMul to scale up the total supply. This is evident because:

  1. The normalized debt index increases over time to represent accrued interest

  2. Raw debt balances should be multiplied by this index to get the current debt amount

  3. Using rayDiv instead of rayMul results in decreasing the total supply when it should increase

Below is a Proof of Concept that shows that there is a missmatch between the balance of the user and the totalSupply. After borrowing the total liquidty of the LendingPool the total usage and the totalSupply are out of sync, while the users DebtToken balance shows the real amount.

Reserve Data
totalLiquidity 0
- totalUsage 980690944600260272998
liquidityIndex 1009845890410958904109589041
usageIndex 1019886942862361165769120316
lastUpdateTimestamp 1036801
underlyingCrvUsdReserve 0
================================================
Printing address balance for: borrower
crvUSD balance 1000000000000000000000
- Balance of DebtToken 1020084688110566574628
+ scaledBalance DebtToken 1000193889381160648477
================================================
- totalSupply 980690944600260272998
+ scaledTotalSupply 1000193889381160648477

PoC

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";
import {WadRayMath} from "../../contracts/libraries/math/WadRayMath.sol";
contract FoundryTest is Test {
using PercentageMath for uint256;
using WadRayMath 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;
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);
// 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);
}
function test_DebtTokenTotalSupplyWrong() public {
// Setup
uint256 initialDeposit = 1000e18;
uint256 housePrice = 1000e18;
// Overwrite house price from setup to a higher value
raacHousePrices.setHousePrice(TOKEN_ID, housePrice);
address lender = makeAddr("lender");
address borrower = makeAddr("borrower");
// Mint required crvUSD for lender and borrower
crvusd.mint(lender, initialDeposit);
crvusd.mint(borrower, housePrice);
assertEq(crvusd.balanceOf(lender), initialDeposit);
assertEq(crvusd.balanceOf(borrower), housePrice);
assertEq(crvusd.balanceOf(address(rToken)), 0);
// Lender deposits crvUSD to the pool
vm.startPrank(lender);
crvusd.approve(address(lendingPool), initialDeposit);
lendingPool.deposit(initialDeposit);
// crvUsd gets transferred to the rToken contract when deposited
assertEq(crvusd.balanceOf(address(rToken)), initialDeposit);
vm.stopPrank();
// borrower mints NFT and deposits it to the lending pool to borrow crvUSD
vm.startPrank(borrower);
crvusd.approve(address(raacNFT), housePrice);
raacNFT.mint(TOKEN_ID, housePrice);
assertEq(crvusd.balanceOf(borrower), 0);
assertEq(raacNFT.balanceOf(borrower), 1);
raacNFT.approve(address(lendingPool), TOKEN_ID);
lendingPool.depositNFT(TOKEN_ID);
assertEq(raacNFT.balanceOf(address(lendingPool)), 1);
// borrow half of the house price
lendingPool.borrow(housePrice / 2);
assertEq(crvusd.balanceOf(borrower), housePrice / 2);
// wait some time to accrue interest on DebtToken
vm.warp(block.timestamp + 10 days);
lendingPool.updateState();
// borrow crvUSD again
lendingPool.borrow(housePrice / 2);
vm.stopPrank();
_printReserveState();
_printAddressBalance(borrower, "borrower");
assertEq(crvusd.balanceOf(borrower), housePrice);
// total supply
uint256 totalSupply = debtToken.totalSupply();
console2.log("totalSupply", totalSupply);
// scaled total supply
uint256 scaledTotalSupply = debtToken.scaledTotalSupply();
console2.log("scaledTotalSupply", scaledTotalSupply);
// Total supply is scaled down and it's less than scaledTotalSupply
assertLt(totalSupply, scaledTotalSupply);
// Total supply is less than the balance of the borrower
assertNotEq(totalSupply, debtToken.balanceOf(borrower));
// The scaledTotalSupply matches the scaled balance of the borrower
assertEq(scaledTotalSupply, debtToken.scaledBalanceOf(borrower));
}
function _printAddressBalance(address addr, string memory label) internal view {
console2.log("Printing address balance for:", label);
console2.log("crvUSD balance", crvusd.balanceOf(addr));
console2.log("Balance of DebtToken", debtToken.balanceOf(addr));
console2.log("scaledBalance DebtToken", debtToken.scaledBalanceOf(addr));
console2.log("================================================");
}
function _printReserveState() internal view {
(
address reserveRTokenAddr,
address reserveAssetAddr,
address reserveDebtTokenAddr,
uint256 totalLiquidity,
uint256 totalUsage,
uint128 liquidityIndex,
uint128 usageIndex,
uint40 lastUpdateTimestamp
) = lendingPool.reserve();
uint256 underlyingCrvUsdReserve = crvusd.balanceOf(address(rToken));
console2.log("Reserve Data");
console2.log("totalLiquidity", totalLiquidity);
console2.log("totalUsage", totalUsage);
console2.log("liquidityIndex", liquidityIndex);
console2.log("usageIndex", usageIndex);
console2.log("lastUpdateTimestamp", lastUpdateTimestamp);
console2.log("underlyingCrvUsdReserve", underlyingCrvUsdReserve);
console2.log("================================================");
}
}

Impact

  • Protocol appears healthier than it actually is

  • Protocol risk metrics are inaccurate

  • Could lead to over-leveraging

  • Interest accrual is not properly reflected in total supply

  • Misleading protocol statistics

Tools Used

  • Foundry

  • Manual Review

Recommendations

Fix the Scaling Direction (see AAVE implementation) []

function totalSupply() public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledSupply = super.totalSupply();
// Fix: Use rayMul instead of rayDiv
return scaledSupply.rayMul(ILendingPool(_reservePool).getNormalizedDebt());
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months 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 4 months 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.