Core Contracts

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

`getNormalizedIncome` doesn't return the actual `liquidityIndex` when `rToken` is transferred

Summary

This happens due to the strange way the liquidity index is upgraded

Vulnerability Details

By taking a look at the ReserveLib::deposit function, we see the following setup:

function deposit(
ReserveData storage reserve,
ReserveRateData storage rateData,
uint256 amount,
address depositor
) internal returns (uint256 amountMinted) {
if (amount < 1) revert InvalidAmount();
// Update reserve interests
updateReserveInterests(reserve, rateData);
// Transfer asset from caller to the RToken contract
IERC20(reserve.reserveAssetAddress).safeTransferFrom(
msg.sender, // from
reserve.reserveRTokenAddress, // to
amount // amount
);
// Mint RToken to the depositor (scaling handled inside RToken)
(
bool isFirstMint,
uint256 amountScaled,
uint256 newTotalSupply,
uint256 amountUnderlying
) = IRToken(reserve.reserveRTokenAddress).mint(
address(this), // caller
depositor, // onBehalfOf
amount, // amount
reserve.liquidityIndex // index
);
amountMinted = amountScaled;
// Update the total liquidity and interest rates
updateInterestRatesAndLiquidity(reserve, rateData, amount, 0);
emit Deposit(depositor, amount, amountMinted);
return amountMinted;
}

First thing done in the function is to call the updateReserveInterests function, which will update the liquidity index as can be seen here:

function updateReserveInterests(
ReserveData storage reserve,
ReserveRateData storage rateData
) internal {
uint256 timeDelta = block.timestamp -
uint256(reserve.lastUpdateTimestamp);
if (timeDelta < 1) {
return;
}
uint256 oldLiquidityIndex = reserve.liquidityIndex;
if (oldLiquidityIndex < 1) revert LiquidityIndexIsZero();
// Update liquidity index using linear interest
@> reserve.liquidityIndex = calculateLiquidityIndex(
rateData.currentLiquidityRate,
timeDelta,
reserve.liquidityIndex
);

Then at the end of the ReserveLib::deposit function, the ReserveLib::updateInterestRatesAndLiquidity is called and as can be seen in the following block of code, the updateInterestRatesAndLiquidity function aims to call the updateReserveInterests function again:

function updateInterestRatesAndLiquidity(
ReserveData storage reserve,
ReserveRateData storage rateData,
uint256 liquidityAdded,
uint256 liquidityTaken
) internal {
.
.
.
@> updateReserveInterests(reserve, rateData);
emit InterestRatesUpdated(
rateData.currentLiquidityRate,
rateData.currentUsageRate
);
}

This time nothing will be updated because of the following check in the updateReserveInterests function:

function updateReserveInterests(
ReserveData storage reserve,
ReserveRateData storage rateData
) internal {
uint256 timeDelta = block.timestamp -
uint256(reserve.lastUpdateTimestamp);
@> if (timeDelta < 1) {
return;
}

This means that after someone deposit, the liquidity index won't actually be updated until the next deposit or withdraw, since this same thing happens with the withdraw function. This won't affect the minting or burning of tokens since they only happen right after the state is updated, but when tokens are transferred it would be a big problem. Example of this can be the StabilityPool contract, where rTokens are always transferred. This is important because of the overriden transfer and transferFrom (This happens in the transferFrom because of the overriden _update function) functions in the rToken contract and the fact that if the liquidity index is not properly fetched, the transferred amount will be inaccurate:

function transfer(
address recipient,
uint256 amount
) public override(ERC20, IERC20) returns (bool) {
uint256 scaledAmount = amount.rayDiv(
@> ILendingPool(_reservePool).getNormalizedIncome()
);
return super.transfer(recipient, scaledAmount);
}
function _update(
address from,
address to,
uint256 amount
) internal override {
// Scale amount by normalized income for all operations (mint, burn, transfer)
uint256 scaledAmount = amount.rayDiv(
@> ILendingPool(_reservePool).getNormalizedIncome()
);
super._update(from, to, scaledAmount);
}

Impact

The getNormalizedIncome function returns inaccurate liquidity index practically every time when used during minting/burning of RTokens, which will affect the following functions: RToken::totalSupply, RToken::transfer, RToken::balanceOf,RToken::trasferFrom and will result in inaccurate amounts of RToken being transferred from StabilityPool to users and from users to StabilityPool

Tools Used

Manual Review

Recommendations

Instead of just returning the reserve.liquidityIndex in the getNormalizedIncome function, just return the ReserveLib::getNormalizedIncome function instead. It will calculate the proper liquidity index and wont impact the already working functionality

Updates

Lead Judging Commences

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

LendingPool::getNormalizedIncome() and getNormalizedDebt() returns stale data without updating state first, causing RToken calculations to use outdated values

Support

FAQs

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