Core Contracts

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

Inaccurate Dust Calculation in RToken::calculateDustAmount() Due to Stale Liquidity Indices

Summary

calculateDustAmount() calculates dust amounts based on the underlying asset balance and total supply, scaled by the liquidity index (getNormalizedIncome()). But, it relies on the static reserve.liquidityIndex from LendingPool, which may be outdated if updateReserveState() hasn’t been called recently. This staleness can lead to incorrect dust calculations, potentially causing minor over-transfers of dust in transferAccruedDust(), resulting in small protocol losses or accounting discrepancies.

Vulnerability Details

https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/tokens/RToken.sol#L317

calculateDustAmount() is defined as:

function calculateDustAmount() public view returns (uint256) {
// Calculate the actual balance of the underlying asset held by this contract
uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this)).rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
// Calculate the total real obligations to the token holders
uint256 currentTotalSupply = totalSupply();
// Calculate the total real balance equivalent to the total supply
uint256 totalRealBalance = currentTotalSupply.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
// All balance, that is not tied to rToken are dust (can be donated or is the rest of exponential vs linear)
return contractBalance <= totalRealBalance ? 0 : contractBalance - totalRealBalance;
}

This function computes dust as the excess of the underlying asset balance (IERC20.balanceOf(address(this))) over the scaled total supply (totalSupply().rayMul(getNormalizedIncome())), both normalized by the liquidity index (getNormalizedIncome()).

The LendingPool.getNormalizedIncome() function returns:

function getNormalizedIncome() external view returns (uint256) {
return reserve.liquidityIndex;
}

But, reserve.liquidityIndex is only updated when ReserveLibrary.updateReserveState() is called during state-modifying operations (like deposit(), withdraw()). If significant time passes since the last update (tracked by reserve.lastUpdateTimestamp), reserve.liquidityIndex becomes stale, failing to reflect accrued interest on the underlying asset.

In contrast, ReserveLibrary.getNormalizedIncome() ( computes a dynamic liquidity index:

function getNormalizedIncome(ReserveData storage reserve, ReserveRateData storage rateData) internal view returns (uint256) {
uint256 timeDelta = block.timestamp - uint256(reserve.lastUpdateTimestamp);
if (timeDelta < 1) {
return reserve.liquidityIndex;
}
return calculateLinearInterest(rateData.currentLiquidityRate, timeDelta, reserve.liquidityIndex).rayMul(reserve.liquidityIndex);
}

calculateDustAmount() uses the static reserve.liquidityIndex instead of this dynamic value, risking an underestimation of totalRealBalance (due to missing interest) and an overestimation of dust.

This issue impacts transferAccruedDust()

https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/tokens/RToken.sol#L351

function transferAccruedDust(address recipient, uint256 amount) external onlyReservePool {
if (recipient == address(0)) revert InvalidAddress();
uint256 poolDustBalance = calculateDustAmount();
if(poolDustBalance == 0) revert NoDust();
// Cap the transfer amount to the actual dust balance
uint256 transferAmount = (amount < poolDustBalance) ? amount : poolDustBalance;
// Transfer the amount to the recipient
IERC20(_assetAddress).safeTransfer(recipient, transferAmount);
emit DustTransferred(recipient, transferAmount);
}

If dust is overestimated due to a stale index, transferAccruedDust() may transfer more than the true excess, reducing the protocol’s liquidity.

Impact

  • Overestimated dust could lead to small over-transfers, depleting the protocol’s underlying asset balance. Given dust’s typically small scale (e.g., rounding errors or residual balances), losses are limited but accumulate over time or frequent transfers.

  • Discrepancies between reported dust and actual balances could skew internal accounting, though totalSupply() and asset balances remain correct

Tools Used

  • Manual code review of RToken.sol, LendingPool.sol, and ReserveLibrary.sol.

Recommendations

  • Modify calculateDustAmount() to use the dynamic liquidity index from ReserveLibrary.getNormalizedIncome() instead of LendingPool.getNormalizedIncome()

This will require you to expose reserve and rateData from LendingPool as public or nternal getter

Updates

Lead Judging Commences

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

RToken::calculateDustAmount incorrectly applies liquidity index, severely under-reporting dust amounts and permanently trapping crvUSD in contract

Support

FAQs

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

Give us feedback!