Core Contracts

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

Inconsistent Debt Tracking Between LendingPool and DebtToken Leads to Incorrect Repayments and Underflows

Summary

A discrepancy exists between how LendingPool and DebtToken track user debt. When a user borrows assets, LendingPool updates its internal scaledDebtBalance, while DebtToken mints debt tokens with an additional balance increase (accrued interest since the last borrow). This creates an inconsistency where the DebtToken balance exceeds the user's scaledDebtBalance in LendingPool.

This inconsistency introduces two critical issues:

  • Users can clear their debt with less than the actual debt amount, leading to a potential loss of revenue for the protocol.

  • Repayment underflows occur when a user attempts to repay based on their debt token balance, leading to a Denial-of-Service (DOS) where repayment is permanently broken.

Vulnerability Details

When a user has deposited a RAACNft into the protocol, they are allowed to borrow assets from the rToken via LendingPool::borrow. See below:

/**
* @notice Allows a user to borrow reserve assets using their NFT collateral
* @param amount The amount of reserve assets to borrow
*/
function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (isUnderLiquidation[msg.sender]) revert CannotBorrowUnderLiquidation();
UserData storage user = userData[msg.sender];
uint256 collateralValue = getUserCollateralValue(msg.sender);
if (collateralValue == 0) revert NoCollateral();
// Update reserve state before borrowing
ReserveLibrary.updateReserveState(reserve, rateData);
// Ensure sufficient liquidity is available
_ensureLiquidity(amount);
// Fetch user's total debt after borrowing
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
// Ensure the user has enough collateral to cover the new debt
if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}
// Update user's scaled debt balance
uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
// 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);
// Rebalance liquidity after borrowing
_rebalanceLiquidity();
emit Borrow(msg.sender, amount);
}

Notice how the normalized debt amount (scaledAmount) is always added to the user's total scaled debt balance. user.scaledDebtBalance is the main metric used to determine whether a user is in debt or not. This value should ideally match the scaled debt balance of the user in the debt token contract for proper accounting in the system.
As a user borrows tokens from the rToken contract, they are simultaneously minted non transferable Debt tokens via DebtToken::mint. See 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 discrepancy lies in the way the mint function works. Specifically:

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;

DebtToken::balanceIncrease which is amount of interest the user has accumulated since their last borrow is added to their current amount and DebtToken::amountToMint is then normalized and minted to the user which is a different value to user.scaledDebtBalance recorded in LendingPool::borrow. As a result, the user's debt token balance will always be larger than user.scaledDebtBalance which leads to 2 major issues. The first is that assuming that the user's debt token balance is used to represent the user's debt in the system, it means that the user can repay their debt will a lower amount that their actual debt. It also means that if a user tries to clear their debt by attempting to pay off their debt token balance, it will cause an underflow leading to a DOS. See LendingPool::_repay:

function _repay(uint256 amount, address onBehalfOf) internal {
if (amount == 0) revert InvalidAmount();
if (onBehalfOf == address(0)) revert AddressCannotBeZero();
UserData storage user = userData[onBehalfOf];
// Update reserve state before repayment
ReserveLibrary.updateReserveState(reserve, rateData);
// Calculate the user's debt (for the onBehalfOf address)
uint256 userDebt = IDebtToken(reserve.reserveDebtTokenAddress).balanceOf(onBehalfOf);
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
// If amount is greater than userDebt, cap it at userDebt
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount;
uint256 scaledAmount = actualRepayAmount.rayDiv(reserve.usageIndex);
// Burn DebtTokens from the user whose debt is being repaid (onBehalfOf)
// is not actualRepayAmount because we want to allow paying extra dust and we will then cap there
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
// Transfer reserve assets from the caller (msg.sender) to the reserve
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
reserve.totalUsage = newTotalSupply;
user.scaledDebtBalance -= amountBurned;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit Repay(msg.sender, onBehalfOf, actualRepayAmount);
}

Proof Of Code (POC)

These tests were run in LendingPool.test.js in the "Borrow and Repay" describe block
There are 2 tests run. The first shows that the user can clear the debt with less tokens than their debt balance and the second shows the underflow

it("users can clear debt without full repayment", async function () {
//c for testing purposes
const borrowAmount = ethers.parseEther("50");
//c on first borrow for user, the amount of debt tokens is correct as the balanceIncrease remains 0 as the if condition is not run
await lendingPool.connect(user1).borrow(borrowAmount);
const reservedata = await lendingPool.getAllUserData(user1.address);
console.log(`usageindex`, reservedata.usageIndex);
const expecteddebt = await reserveLibrary.raymul(
reservedata.scaledDebtBalance,
reservedata.usageIndex
);
console.log(`expecteddebt`, expecteddebt);
//c note that the balanceOf function gets the actual debt by multiplying the normalized debt by the usage index. See DebtToken::balanceOf
const debtBalance = await debtToken.balanceOf(user1.address);
console.log("debtBalance", debtBalance);
//c proof that the expectedamount is debt is the same as the debt balance after first deposit
assert(expecteddebt == debtBalance);
//c allow time to pass so that the usage index is updated
await time.increase(365 * 24 * 60 * 60);
//c on second borrow for user, the amount of debt tokens differs from the users scaleddebtbalance in the LendingPool contract as the balanceIncrease is not 0 as the if condition is run
await lendingPool.connect(user1).borrow(borrowAmount);
const reservedata1 = await lendingPool.getAllUserData(user1.address);
console.log(`secondborrowusageindex`, reservedata1.usageIndex);
const secondborrowexpecteddebt = await reserveLibrary.raymul(
reservedata1.scaledDebtBalance,
reservedata1.usageIndex
);
console.log(reservedata1.scaledDebtBalance);
console.log(`secondborrowexpecteddebt`, secondborrowexpecteddebt);
const secondborrowdebtBalance = await debtToken.balanceOf(user1.address);
//c proof that the expecteddebt is now less than the debt balance after second borrow which shows inconsistency
assert(secondborrowdebtBalance > secondborrowexpecteddebt);
//c user will be able to repay their full debt but they will still have debt tokens leftover which shows the inconsistency between scaled debt in the lending pool and the debt token which means that a user can create a scenario where the LendingPool thinks that user has cleared their debt when in reality, they still have debt according to the debt token contract
await lendingPool.connect(user1).repay(secondborrowexpecteddebt);
const reservedata2 = await lendingPool.getAllUserData(user1.address);
console.log(`userscaleddebt`, reservedata2.scaledDebtBalance);
const postrepayexpecteddebt = await reserveLibrary.raymul(
reservedata2.scaledDebtBalance,
reservedata2.usageIndex
);
const postrepaydebtBalance = await debtToken.balanceOf(user1.address);
console.log("postrepaydebtBalance", postrepaydebtBalance);
assert(postrepaydebtBalance > postrepayexpecteddebt);
assert(postrepayexpecteddebt == 0);
});
it("underflow occurs in repay", async function () {
//c for testing purposes
const borrowAmount = ethers.parseEther("50");
//c on first borrow for user, the amount of debt tokens is correct as the balanceIncrease remains 0 as the if condition is not run
await lendingPool.connect(user1).borrow(borrowAmount);
const reservedata = await lendingPool.getAllUserData(user1.address);
console.log(`usageindex`, reservedata.usageIndex);
const expecteddebt = await reserveLibrary.raymul(
reservedata.scaledDebtBalance,
reservedata.usageIndex
);
console.log(`expecteddebt`, expecteddebt);
//c note that the balanceOf function gets the actual debt by multiplying the normalized debt by the usage index. See DebtToken::balanceOf
const debtBalance = await debtToken.balanceOf(user1.address);
console.log("debtBalance", debtBalance);
//c proof that the expectedamount is debt is the same as the debt balance after first deposit
assert(expecteddebt == debtBalance);
//c allow time to pass so that the usage index is updated
await time.increase(365 * 24 * 60 * 60);
//c on second borrow for user, the amount of debt tokens is inflated as the balanceIncrease is not 0 as the if condition is run
await lendingPool.connect(user1).borrow(borrowAmount);
const reservedata1 = await lendingPool.getAllUserData(user1.address);
console.log(`secondborrowusageindex`, reservedata1.usageIndex);
const secondborrowexpecteddebt = await reserveLibrary.raymul(
reservedata1.scaledDebtBalance,
reservedata1.usageIndex
);
console.log(reservedata1.scaledDebtBalance);
console.log(`secondborrowexpecteddebt`, secondborrowexpecteddebt);
const secondborrowdebtBalance = await debtToken.balanceOf(user1.address);
console.log("secondborrowdebtBalance", secondborrowdebtBalance);
//c proof that the expecteddebt is now less than the debt balance after second borrow which shows discrepancy and leads to underflow
assert(secondborrowdebtBalance > secondborrowexpecteddebt);
//c when user attempts to repay their debt according to the debt token contract balanceOf function, there will be an underflow as the expected debt is less than the debt balance
await expect( lendingPool.connect(user1).repay(secondborrowdebtBalance)).to
.be.reverted;
});

Impact

Users can repay less than they actually owe, leading to a loss of funds for the protocol.
Denial-of-Service (DOS): If users attempt to fully repay their debt, an underflow occurs, causing the transaction to revert and preventing repayment.
Bad Debt Accumulation: The protocol could end up with unaccounted liabilities, making liquidation and debt resolution unreliable.

Tools Used

Manual Review, Hardhat

Recommendations

To resolve this issue, remove the balanceIncrease addition in DebtToken::mint, ensuring that the minted debt tokens match the scaled debt in LendingPool.

Current Code

uint256 amountToMint = amount + balanceIncrease;

Refactored Code

uint256 amountToMint = amount;

This ensures that DebtToken::balanceOf(user) correctly reflects LendingPool::user.scaledDebtBalance, preventing underpayment of debt, repayment underflows, and Denial-of-Service attacks.

Updates

Lead Judging Commences

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