Core Contracts

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

Excess Debt token minting

Summary

The DebtToken.sol contract mints excess debt tokens across multiple borrows from the lending pool contract LendingPool.sol resulting in incorrect total debt, usage rate and liquidity rate.

Vulnerability Details

The mint() function in DebtToken.sol before minting new tokens, calculates the user's scaled balance wrongly thus resulting in a wrong balanceIncrease.
As seen in the mint() function below,

/**
* @notice Mints debt tokens to a user
* @param user The address initiating the mint
* @param onBehalfOf The recipient of the debt tokens
* @param amount The amount to mint (in underlying asset units)
* @param index The usage index at the time of minting
* @return A tuple containing:
* - bool: True if the previous balance was zero
* - uint256: The amount of scaled tokens minted
* - uint256: The new total supply after minting
*/
function mint(
address user,
address onBehalfOf,
uint256 amount,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256) {
if (user == address(0) || onBehalfOf == address(0)) revert InvalidAddress();
if (amount == 0) {
return (false, 0, totalSupply());
}
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
uint256 amountToMint = amount + balanceIncrease;
_mint(onBehalfOf, amountToMint.toUint128());
emit Transfer(address(0), onBehalfOf, amountToMint);
emit Mint(user, onBehalfOf, amountToMint, balanceIncrease, index);
return (scaledBalance == 0, amountToMint, totalSupply());
}

the user's scaled balance scaledBalance is calculated as follows

uint256 scaledBalance = balanceOf(onBehalfOf);

the balanceOf() function from DebitToken.sol is ivoked and it returns the user's balance adjusting for the current debt index or usuageIndex as shown below.

/**
* @notice Returns the scaled debt balance of the user
* @param account The address of the user
* @return The user's debt balance (scaled by the usage index)
*/
function balanceOf(address account) public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledBalance = super.balanceOf(account);
return scaledBalance.rayMul(ILendingPool(_reservePool).getNormalizedDebt());
}

the balanceIncrease as calculated in the mint() function applies the current debt index once more to the user's balance snapshot thus inflating the increase in balance as follows

balanceIncrease = balance*(cur_index)*(cur_index) - balance*(cur_index)*(prev_index)

as seen in the code below

uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}

the amountToMint value minted by _mint() -> _update() (internal overridden) in DebtToken.sol is thus in excess of what it should be.

The totalSupply() calculated is thus wrong and it reflects in the totalDebt in the LendingPool.sol as shown below

(in mint() in DebtToken.sol)

return (scaledBalance == 0, amountToMint, totalSupply());

(in borrow() in LendingPool.sol)

// Mint DebtTokens to the user (scaled amount)
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
// Transfer borrowed amount to user
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
user.scaledDebtBalance += scaledAmount;
// reserve.totalUsage += amount;
reserve.totalUsage = newTotalSupply;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, 0, amount);

(in updateInterestRatesAndLiquidity in ReserveLibrary.sol)

// Calculate utilization rate
uint256 utilizationRate = calculateUtilizationRate(reserve.totalLiquidity, reserve.totalUsage);

(in calculateUtilizationRate in ReserveLibrary.sol)

uint256 utilizationRate = totalDebt.rayDiv(totalLiquidity + totalDebt).toUint128();

(in updateInterestRatesAndLiquidity in ReserveLibrary.sol)

// Update current usage rate (borrow rate)
rateData.currentUsageRate = calculateBorrowRate(
rateData.primeRate,
rateData.baseRate,
rateData.optimalRate,
rateData.maxRate,
rateData.optimalUtilizationRate,
utilizationRate
);
// Update current liquidity rate
rateData.currentLiquidityRate = calculateLiquidityRate(
utilizationRate,
rateData.currentUsageRate,
rateData.protocolFeeRate,
totalDebt
);

PoC:
The following test performs a double mint with an index of 1.6 RAY between the
two mints. The initial mint is of 100 ETH and the second mint is also of 100 underlying asset units, the user's total balance should be 320 ETH but the
mint() function mints a total of 356 ETH so there is a excess of 36 ETH minted which is wrong.

Copy the following in DebtToken.test-h1.js and
run the command npx hardhat test test/unit/core/tokens/DebtToken.test-h1.js

import { expect } from "chai";
import hre from "hardhat";
const { ethers, network } = hre;
describe("DebtToken", function () {
this.timeout(50000); // Increase timeout to 50 seconds
let DebtToken;
let debtToken;
let owner;
let user1;
let user2;
let addrs;
let MockLendingPool;
let mockLendingPool;
let mockLendingPoolSigner;
const RAY = ethers.getBigInt("1000000000000000000000000000"); // 1e27
beforeEach(async function () {
[owner, user1, user2, ...addrs] = await ethers.getSigners();
// Deploy mock lending pool first
const MockLendingPoolFactory = await ethers.getContractFactory("MockLendingPoolDebtToken", owner);
mockLendingPool = await MockLendingPoolFactory.deploy();
await mockLendingPool.waitForDeployment();
const mockLendingPoolAddress = await mockLendingPool.getAddress();
// Set up mock lending pool
await mockLendingPool.setNormalizedDebt(RAY); // Set initial normalized debt to 1
// Deploy DebtToken
const DebtTokenFactory = await ethers.getContractFactory("DebtToken", owner);
debtToken = await DebtTokenFactory.deploy("DebtToken", "DT", owner.address);
await debtToken.waitForDeployment();
const debtTokenAddress = await debtToken.getAddress();
// Set up DebtToken
await debtToken.setReservePool(mockLendingPoolAddress);
// Set up mock lending pool signer with enough ETH
mockLendingPoolSigner = await ethers.getImpersonatedSigner(mockLendingPoolAddress);
await owner.sendTransaction({
to: mockLendingPoolAddress,
value: ethers.parseEther("10.0") // Increase to 10 ETH
});
// Set gas limit for impersonated signer
await network.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]);
await network.provider.send("hardhat_setBalance", [
mockLendingPoolAddress,
"0x56BC75E2D63100000" // 100 ETH
]);
});
describe.only("Double Mint functionality", function() {
beforeEach(async function() {
});
it("should allow ReservePool to mint debt tokens", async function () {
const mintAmount = ethers.parseEther("100");
let index = RAY; // Initial index
console.log("Attempting to mint", mintAmount.toString(), "tokens with index", index.toString());
let tx = await debtToken.connect(mockLendingPoolSigner).mint(user1.address, user1.address, mintAmount, index);
let receipt = await tx.wait();
console.log("Mint transaction successful, gas used:", receipt.gasUsed.toString());
let balance = await debtToken.balanceOf(user1.address);
console.log("User balance after mint:", balance.toString());
expect(balance).to.equal(mintAmount);
// Set up mock lending pool
const RAY_1_6 = ethers.getBigInt("1600000000000000000000000000"); // 1.6e27
await mockLendingPool.setNormalizedDebt(RAY_1_6); // Set normalized debt to 1.6
index = ethers.parseUnits("1.6", 27); // 1.6 RAY
console.log("Attempting to mint additional", mintAmount.toString(), "tokens with index", index.toString());
tx = await debtToken.connect(mockLendingPoolSigner).mint(user1.address, user1.address, mintAmount, index);
receipt = await tx.wait();
console.log("Mint transaction successful, gas used:", receipt.gasUsed.toString());
balance = await debtToken.balanceOf(user1.address);
console.log("User balance after 2nd mint:", balance.toString());
expect(balance).to.equal(ethers.getBigInt("320000000000000000000"));
});
});
});

Impact

The user is made liable for excess debt than what it actually should be and the total debt wrongly reflects in Lending Pool in terms of the usuage rate and liquidity rate.

Tools Used

Manual Review

Recommended Mitigation

Fix the scaledBalance of the user before minting by calling super.balanceOf() instead of balanceOf() as shown below.

function mint(
address user,
address onBehalfOf,
uint256 amount,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256) {
if (user == address(0) || onBehalfOf == address(0)) revert InvalidAddress();
if (amount == 0) {
return (false, 0, totalSupply());
}
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
-- uint256 scaledBalance = balanceOf(onBehalfOf);
++ uint256 scaledBalance = super.balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
uint256 amountToMint = amount + balanceIncrease;
_mint(onBehalfOf, amountToMint.toUint128());
emit Transfer(address(0), onBehalfOf, amountToMint);
emit Mint(user, onBehalfOf, amountToMint, balanceIncrease, index);
return (scaledBalance == 0, amountToMint, totalSupply());
}
Updates

Lead Judging Commences

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

DebtToken::mint miscalculates debt by applying interest twice, inflating borrow amounts and risking premature liquidations

Support

FAQs

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