Core Contracts

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

Incorrect Debt Scaling Leading to Protocol Solvency Risk

Summary

The DebtToken contract in a lending protocol uses a scaled balance mechanism with a usage index to track user debt, incorporating interest accrual over time. However, the burn function, which handles debt repayment, fails to correctly adjust the repayment amount when the usage index changes between transaction submission and execution. This results in users underpaying their debt, potentially accumulating uncollected debt and jeopardizing the protocol's financial stability.

Vulnerability Details

  • Root Cause: The burn function in DebtToken takes a repayment amount and an index parameter, but it does not account for changes in the usage index (getNormalizedDebt) fetched from the lending pool. Specifically:

    • When a user submits a transaction to repay a certain amount, the index is based on the usage index at submission time.

    • If the usage index increases due to interest accrual before the transaction executes, balanceOf reflects the updated debt, but the amount to burn remains unadjusted.

    • The function uses rayMul and rayDiv to scale balances, but the logic lacks atomic updates or validation to ensure the amount matches the current scaled debt.

function burn(
address from,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256, uint256) {
if (from == address(0)) revert InvalidAddress();
if (amount == 0) {
return (0, totalSupply(), 0, 0);
}
uint256 userBalance = balanceOf(from);
uint256 balanceIncrease = 0;
if (_userState[from].index != 0 && _userState[from].index < index) {
uint256 borrowIndex = ILendingPool(_reservePool).getNormalizedDebt();
balanceIncrease = userBalance.rayMul(borrowIndex) - userBalance.rayMul(_userState[from].index);
amount = amount; // Bug: No adjustment for balanceIncrease!
}
_userState[from].index = index.toUint128();
if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
_burn(from, amount.toUint128()); // Burns unscaled amount, not amountScaled
emit Burn(from, amountScaled, index);
return (amount, totalSupply(), amountScaled, balanceIncrease);
}

Test (POC):

it("should leave Remaining debt when index increases before burn", async function () {
const mintAmount = ethers.parseEther("100");
const initialIndex = RAY;
const increasedIndex = RAY * 11n / 10n; // 1.1 (10% increase)
// Mint debt at initial index
await debtToken.connect(mockLendingPoolSigner).mint(user1.address, user1.address, mintAmount, initialIndex);
let balance = await debtToken.balanceOf(user1.address);
expect(balance).to.equal(mintAmount); // 100
// Increase index to simulate interest
await mockLendingPool.setNormalizedDebt(increasedIndex);
balance = await debtToken.balanceOf(user1.address);
expect(balance).to.equal(mintAmount * increasedIndex / RAY); // 110
// Burn with original amount and initial index to simulate repayment
await debtToken.connect(mockLendingPoolSigner).burn(user1.address, mintAmount, initialIndex);
balance = await debtToken.balanceOf(user1.address);
expect(balance).to.be.gt(0); // Should still have some Remaining debt
});

Test Output:

npx hardhat test --grep "should leave Remaining debt when index increases before burn"
DebtToken
✔ should leave Remaining debt when index increases before burn (174ms)
1 passing (2s)

https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/tokens/DebtToken.sol#L181-L214

Impact

  • Underpayment of Debt: Users repay less than their actual debt, leaving residual balances that continue to accrue interest.

  • Bad Debt Accumulation: Over time, uncollected debt accumulates, increasing the protocol’s financial liability.

Tools Used

Manual

Recommendations

Use Atomic Index Updates: Remove the index parameter from burn and fetch the latest usage index (ILendingPool(_reservePool).getNormalizedDebt()) directly and atomically at execution time.
Adjust Burn Amount for Interest: Calculate the user’s current debt using balanceOf and adjust the amount to burn to include any balanceIncrease, ensuring full repayment.
Scale Correctly with Current Index: Use the current borrowIndex (not the provided index) in rayDiv to calculate amountScaled, and burn the amountScaled value in _burn to ensure consistency with scaled balances.
Update Return Values: Ensure return values (amount, amountScaled, balanceIncrease) reflect the corrected burn logic for accurate off-chain tracking.

function burn(
address from,
uint256 amount,
- uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256, uint256) {
if (from == address(0)) revert InvalidAddress();
if (amount == 0) {
return (0, totalSupply(), 0, 0);
}
+ uint256 borrowIndex = ILendingPool(_reservePool).getNormalizedDebt();
uint256 userBalance = balanceOf(from); // Already uses current index
uint256 balanceIncrease = 0;
- if (_userState[from].index != 0 && _userState[from].index < index) {
+ if (_userState[from].index != 0 && _userState[from].index < borrowIndex) {
- uint256 borrowIndex = ILendingPool(_reservePool).getNormalizedDebt();
balanceIncrease = userBalance.rayMul(borrowIndex) - userBalance.rayMul(_userState[from].index);
- amount = amount;
}
_userState[from].index = index.toUint128();
- if(amount > userBalance){
- amount = userBalance;
- }
+ uint256 amountToBurn = amount > userBalance ? userBalance : amount + balanceIncrease;
- uint256 amountScaled = amount.rayDiv(index);
+ uint256 amountScaled = amountToBurn.rayDiv(borrowIndex);
if (amountScaled == 0) revert InvalidAmount();
- _burn(from, amount.toUint128());
+ _burn(from, amountScaled.toUint128());
- emit Burn(from, amountScaled, index);
+ emit Burn(from, amountScaled, borrowIndex);
- return (amount, totalSupply(), amountScaled, balanceIncrease);
+ return (amountToBurn, totalSupply(), amountScaled, balanceIncrease);
}
Updates

Lead Judging Commences

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

LendingPool::borrow tracks debt as user.scaledDebtBalance += scaledAmount while DebtToken mints amount+interest, leading to accounting mismatch and preventing full debt repayment

DebtToken::burn incorrectly burns amount (asset units) instead of amountScaled (token units), breaking token economics and interest-accrual mechanism

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

LendingPool::borrow tracks debt as user.scaledDebtBalance += scaledAmount while DebtToken mints amount+interest, leading to accounting mismatch and preventing full debt repayment

DebtToken::burn incorrectly burns amount (asset units) instead of amountScaled (token units), breaking token economics and interest-accrual mechanism

Support

FAQs

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