Core Contracts

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

Incorrect Total Supply Calculation in DebtToken

Overview

The DebtToken contract is designed to track user debt in the RAAC lending protocol using a scaled balance mechanism inspired by Aave’s VariableDebtToken. Each user’s “actual” debt balance is calculated from an internally stored “scaled” balance and a usage (or reserve) index obtained from the LendingPool. While the contract correctly computes an individual user’s debt balance via an override of balanceOf—multiplying the stored (scaled) balance by the normalized debt—the override for totalSupply instead divides the stored scaled total supply by the normalized debt. This inversion leads to a severe miscalculation of the overall outstanding debt.

Root Cause

  • Standard Mechanism:
    In variable debt tokens, the actual balance is typically computed as:

    where RAY is a fixed scaling factor (typically 1e27).

  • Implementation in DebtToken:

    • The balanceOf function correctly returns:

      return scaledBalance.rayMul(ILendingPool(_reservePool).getNormalizedDebt());

      This calculates

    • However, the totalSupply function returns:

      return scaledSupply.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());

      which computes

  • Inversion Effect:
    Instead of scaling the total supply up by the normalized debt (as with individual balances), it scales it down. For example, if the normalized debt is greater than RAY (indicating accrued interest), an account’s balance is inflated relative to its stored value, but the total supply is deflated. This discrepancy results in inconsistent and erroneous overall debt accounting.

Impact

  • Inaccurate Debt Representation:
    The total supply of DebtToken, which represents the aggregate outstanding debt in the protocol, will be significantly misreported. For instance, if the normalized debt is 2×RAY, a user’s balance would be doubled, while totalSupply would be halved relative to their true cumulative debt.

  • Downstream Effects:
    This miscalculation can have severe consequences on:

    • Interest accrual computations,

    • Liquidation triggers,

    • Reward and fee distribution,

    • Overall risk management across the lending protocol.

    Such an error could lead to either an underestimation or overestimation of system-wide debt, potentially causing improper liquidations or misallocated rewards, and undermining confidence in the protocol’s financial integrity.

Foundry PoC Demonstration

simplified Foundry test demonstrates the inconsistency between balanceOf and totalSupply when the normalized debt is set above RAY. For demonstration purposes, we simulate the LendingPool’s getNormalizedDebt function via a mock contract.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "../../libraries/math/WadRayMath.sol";
// A simplified mock for the LendingPool that provides a normalized debt.
contract MockLendingPool {
uint256 private _normalizedDebt;
constructor(uint256 normalizedDebt_) {
_normalizedDebt = normalizedDebt_;
}
function getNormalizedDebt() external view returns (uint256) {
return _normalizedDebt;
}
}
// Minimal interface for DebtToken as in our contract.
interface IDebtToken {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
}
// DebtToken implementation excerpt (for testing purposes)
contract DebtTokenMock is ERC20, ERC20Permit, Ownable {
using WadRayMath for uint256;
// Simulated storage of scaled balances is managed by ERC20's internal mapping.
address private _reservePool;
// For simplicity, we store the normalized debt externally via the reservePool mock.
constructor(
string memory name,
string memory symbol,
address initialOwner,
address reservePool_
) ERC20(name, symbol) ERC20Permit(name) Ownable(initialOwner) {
_reservePool = reservePool_;
}
// Overridden balanceOf returns: scaledBalance * normalizedDebt / RAY
function balanceOf(address account) public view override(ERC20) returns (uint256) {
uint256 scaledBalance = super.balanceOf(account);
uint256 normalizedDebt = MockLendingPool(_reservePool).getNormalizedDebt();
return scaledBalance * normalizedDebt / 1e27;
}
// Overridden totalSupply returns: scaledTotalSupply * RAY / normalizedDebt (INCORRECT)
function totalSupply() public view override(ERC20) returns (uint256) {
uint256 scaledSupply = super.totalSupply();
uint256 normalizedDebt = MockLendingPool(_reservePool).getNormalizedDebt();
return scaledSupply * 1e27 / normalizedDebt;
}
// For testing, a mint function that directly adds a scaled amount.
function mint(address to, uint256 scaledAmount) external onlyOwner {
_mint(to, scaledAmount);
}
}
contract DebtTokenTest is Test {
DebtTokenMock debtToken;
MockLendingPool mockPool;
// For testing, we set normalizedDebt to 2e27 (twice RAY).
function setUp() public {
mockPool = new MockLendingPool(2e27);
debtToken = new DebtTokenMock("DebtToken", "DEBT", address(this), address(mockPool));
// Mint 100 units of scaled tokens to a test address.
debtToken.mint(address(0xBEEF), 100e18);
}
function testBalanceAndTotalSupplyInconsistency() public {
// Expected balance: scaledBalance * normalizedDebt / RAY = 100e18 * 2e27 / 1e27 = 200e18.
uint256 userBalance = debtToken.balanceOf(address(0xBEEF));
assertEq(userBalance, 200e18, "User balance should be 200e18");
// Expected totalSupply (correctly) should be same as user balance (if only one holder).
// But due to bug, totalSupply returns: scaledSupply * RAY / normalizedDebt = 100e18 * 1e27 / 2e27 = 50e18.
uint256 total = debtToken.totalSupply();
assertEq(total, 50e18, "Total supply is miscalculated (expected 200e18, got 50e18)");
}
}

Explanation:

  • We deploy a mock LendingPool that returns a normalized debt of 2e27 (i.e. twice the base RAY).

  • We mint 100 units (scaled) of debt tokens to an address.

  • The overridden balanceOf correctly computes the user’s actual balance as 200 tokens, but the overridden totalSupply erroneously computes 50 tokens.

  • This discrepancy proves that the total supply calculation is inverted relative to individual balances.

Mitigation

Recommended Fix:
Modify the totalSupply override so that it uses the same scaling factor as balanceOf. In other words, it should multiply the stored scaled total supply by the normalized debt and then divide by RAY. For example, change:

return scaledSupply.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());

to

return scaledSupply.rayMul(ILendingPool(_reservePool).getNormalizedDebt());

This ensures consistency between individual balances and the aggregate total supply, correctly reflecting accrued interest and overall debt in the protocol.

Updates

Lead Judging Commences

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

Give us feedback!