Core Contracts

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

RToken is Not Interest Bearing Due to Broken Liquidity Index Calculation

Summary

The RToken is not interest bearing. During the withdrawal process, the amount owed to the user is not properly scaled to include interest. Specifically, the calculation of liquidity index upon burning during the withdrawal process from LendingPool is broken. No interest is calculated or added and provided to the user.

Vulnerability Details

  • Uninvoked updateLiquidityIndex(): The function updateLiquidityIndex(uint256 newLiquidityIndex), which is responsible for updating the internal _liquidityIndex, is not invoked anywhere in the protocol. As a result, the _liquidityIndex variable in RToken.sol remains fixed at its initial value (1e27). Despite being callable by the Reserve Pool (as enforced by the onlyReservePool modifier), no contract (including the LendingPool) ever calls this function.

  • Stale Liquidity Index: Because _liquidityIndex is initialized to WadRayMath.RAY (1e27) and never updated, the scaling mechanism used in balanceOf(), transferFrom(), and other functions does not apply any interest accrual. The intended dynamic adjustment of user balances based on accrued interest is effectively bypassed.

  • Parallel Liquidity Index Mechanism also not scaled to Deposit: There is a parallel liquidity index update mechanism used in updateReserveInterests()::ReserveLibrary.sol and calculateLiquidityIndex()::ReserveLibrary.sol. These are, however, incorrectly implemented in burn() of RToken.sol. Although the burn() function calculates an "amountScaled" using amount.rayMul(index), it never actually uses that scaled value in the burn operation. Instead, it directly burns amount.toUint128() and returns the unscaled amount, which means that the intended multiplication by the liquidity index does not take effect.

Root Cause:

function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
if (amount == 0) {
return (0, totalSupply(), 0);
}
uint256 userBalance = balanceOf(from);
_userState[from].index = index.toUint128(); //<== This mapping is never updated.
if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayMul(index); //<== Scaled amountScaled is never used
_userState[from].index = index.toUint128();//@audit-Repeating Line 13
_burn(from, amount.toUint128());//<== the burned amount is not scaled. I.e. NO Interest!
if (receiverOfUnderlying != address(this)) {
IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);//<== Non scaled amount is paid to the user. i.e. NO interest.
}
emit Burn(from, receiverOfUnderlying, amount, index);
return (amount, totalSupply(), amount);
}

PoC

Run the test below in LendingPool.test.js with the following command:

npx hardhat test test/unit/core/pools/LendingPool/LendingPool.test.js --show-stack-traces
describe.only("RToken Interest Accrual Failure with Transactions", function () {
it("should not accrue interest over 30 days despite multiple liquidity events", async function () {
// --- Initial Deposits ---
// user1 deposits 1000 crvUSD into the lending pool and receives RToken.
const initialDepositAmountUser1 = ethers.parseEther("1000");
await crvusd
.connect(user1)
.approve(lendingPool.target, initialDepositAmountUser1);
await lendingPool.connect(user1).deposit(initialDepositAmountUser1);
const initialBalanceUser1 = await rToken.balanceOf(user1.address);
expect(initialBalanceUser1).to.equal(initialDepositAmountUser1);
// user2 deposits 500 crvUSD into the lending pool.
const depositAmountUser2 = ethers.parseEther("500");
await crvusd
.connect(user2)
.approve(lendingPool.target, depositAmountUser2);
await lendingPool.connect(user2).deposit(depositAmountUser2);
// --- Simulate Passage of Time ---
// Advance time by 10 days.
await ethers.provider.send("evm_increaseTime", [10 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
// The liquidity index should remain at its initial value (1e27) because updateLiquidityIndex() is never called.
const LiquidityIndex1 = await rToken.getLiquidityIndex();
expect(LiquidityIndex1).to.equal(ethers.parseUnits("1", 27));
// --- Simulate Liquidity Events ---
// user1 withdraws 100 crvUSD from the lending pool.
const withdrawAmountUser1 = ethers.parseEther("100");
await rToken
.connect(user1)
.approve(lendingPool.target, withdrawAmountUser1);
await lendingPool.connect(user1).withdraw(withdrawAmountUser1);
// --- Simulate Passage of Time ---
// Advance time by 10 days.
await ethers.provider.send("evm_increaseTime", [10 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
// The liquidity index should remain at its initial value (1e27) because updateLiquidityIndex() is never called.
const LiquidityIndex2 = await rToken.getLiquidityIndex();
expect(LiquidityIndex2).to.equal(ethers.parseUnits("1", 27));
// user1 withdraws 200 crvUSD from the lending pool.
const withdrawAmountUser2 = ethers.parseEther("200");
await rToken
.connect(user1)
.approve(lendingPool.target, withdrawAmountUser2);
await lendingPool.connect(user1).withdraw(withdrawAmountUser2);
// --- Simulate Passage of Time ---
// Advance time by 10 days.
await ethers.provider.send("evm_increaseTime", [10 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
// The liquidity index should remain at its initial value (1e27) because updateLiquidityIndex() is never called.
const LiquidityIndex3 = await rToken.getLiquidityIndex();
expect(LiquidityIndex3).to.equal(ethers.parseUnits("1", 27));
// --- Final Assertions ---
// user1's crvUSD balance after withdrawing all crvUSD and burning all RToken should be higher than initial crvUSD deposit.
// It is equal to initial deposit therefore no interest accrued.
const finalBalanceUser1 = await rToken.balanceOf(user1.address);
await rToken
.connect(user1)
.approve(lendingPool.target, finalBalanceUser1);
await lendingPool.connect(user1).withdraw(finalBalanceUser1);
const endBal = await crvusd.connect(user1).balanceOf(user1.address);
//If there was interest accrued, the following should revert. It is does not.
expect(await crvusd.balanceOf(user1.address)).to.equal(
initialDepositAmountUser1
);
console.log("bal:", endBal);
});
});

Impact

  • No Interest Accrual for Depositors: Depositors receive RToken without any interest growth, undermining the promise of an interest-bearing asset and reducing the token to a static ERC20 asset.

  • Misleading Token Economics: Functions that rely on a dynamic liquidity index (such as balanceOf() and transferFrom()) perform calculations using the fixed initial value, leading to inaccurate representations of users' actual balances.

  • Protocol Economic Imbalance: The entire economic model of the protocol assumes that deposited assets accrue interest over time. With the liquidity index stagnant, risk assessments, yield calculations, and borrowing limits may be significantly misaligned with the protocol’s design.

Tools Used

Manual Review, Hardhat

Recommendations

  • Ensure that the amountScaled variable in burn() is passed to the _burn().

  • Remove the redundant use of userState mapping. Remove the redundant updateLiquidityIndex() which serves no practical purpose.

Updates

Lead Judging Commences

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

RToken::updateLiquidityIndex() has onlyReservePool modifier but LendingPool never calls it, causing transferFrom() to use stale liquidity index values

RToken::burn transfers original deposit amount (amount) to users instead of amount plus interest (amountScaled), causing loss of all accrued interest on withdrawals

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

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

RToken::updateLiquidityIndex() has onlyReservePool modifier but LendingPool never calls it, causing transferFrom() to use stale liquidity index values

RToken::burn transfers original deposit amount (amount) to users instead of amount plus interest (amountScaled), causing loss of all accrued interest on withdrawals

RToken::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.

Give us feedback!