Core Contracts

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

Redundant Reserve State Updates in LendingPool Deposit/Withdraw Functions Lead to Excessive Gas Consumption

Summary

The LendingPool contract allows users to deposit and withdraw reserve assets, updating reserve state and interest rates via calls to functions in the ReserveLibrary. However, the design currently results in multiple (redundant) calls to update reserve interests. For example, both the LendingPool deposit/withdraw functions and the ReserveLibrary functions they invoke call updateReserveInterests, leading to three updates per deposit operation. While the functionality remains correct, the redundant updates incur unnecessary gas costs and may complicate state tracking. This inefficiency might be exploited or simply lead to higher operational costs, which affects protocol efficiency.

Vulnerability Details

The LendingPool functions call ReserveLibrary functions that update reserve interest and liquidity parameters:

In LendingPool::deposit

function deposit(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
// Update the reserve state before the deposit
// @info: verbose function already called inside ReserveLibrary.deposit
@> ReserveLibrary.updateReserveState(reserve, rateData);
// Perform the deposit through ReserveLibrary
uint256 mintedAmount = ReserveLibrary.deposit(reserve, rateData, amount, msg.sender);
// Rebalance liquidity after deposit
_rebalanceLiquidity();
emit Deposit(msg.sender, amount, mintedAmount);
}

In LendingPool::withdraw

function withdraw(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (withdrawalsPaused) revert WithdrawalsArePaused();
// Update the reserve state before the withdrawal
// @info: verbose function already called inside ReserveLibrary.withdraw
@> ReserveLibrary.updateReserveState(reserve, rateData);
// Ensure sufficient liquidity is available
_ensureLiquidity(amount);
// Perform the withdrawal through ReserveLibrary
(uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) = ReserveLibrary.withdraw(
reserve, // ReserveData storage
rateData, // ReserveRateData storage
amount, // Amount to withdraw
msg.sender // Recipient
);
// Rebalance liquidity after withdrawal
_rebalanceLiquidity();
emit Withdraw(msg.sender, amountWithdrawn);
}

In ReserveLibrary::deposit

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;
}

In ReserveLibrary::updateInterestRatesAndLiquidity

function updateInterestRatesAndLiquidity(
ReserveData storage reserve,
ReserveRateData storage rateData,
uint256 liquidityAdded,
uint256 liquidityTaken
) internal {
// Update total liquidity...
// ...
// Update current usage rate and liquidity rate...
// ...
// Update the reserve interests
@> updateReserveInterests(reserve, rateData);
emit InterestRatesUpdated(rateData.currentLiquidityRate, rateData.currentUsageRate);
}

Redundancy

In the deposit flow:

  1. LendingPool::deposit calls ReserveLibrary.updateReserveState (which likely calls updateReserveInterests internally).

  2. ReserveLibrary::deposit calls updateReserveInterests once.

  3. ReserveLibrary::updateInterestRatesAndLiquidity calls updateReserveInterests again.

As a result, for each deposit operation, updateReserveInterests is executed three times—leading to redundant state updates and unnecessary gas consumption.

Proof of Concept

Test Suite Walkthrough

The provided test suite demonstrates the redundancy by checking update counters:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {StabilityPool} from "../src/core/pools/StabilityPool/StabilityPool.sol";
import {LendingPool} from "../src/core/pools/LendingPool/LendingPool.sol";
import {DEToken} from "../src/core/tokens/DEToken.sol";
import {RToken} from "../src/core/tokens/RToken.sol";
import {DebtToken} from "../src/core/tokens/DebtToken.sol";
import {RAACToken} from "../src/core/tokens/RAACToken.sol";
import {RAACMinter} from "../src/core/minters/RAACMinter/RAACMinter.sol";
import {RAACHousePrices} from "../src/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "../src/core/tokens/RAACNFT.sol";
import {CurveCrvUSDVaultMock} from "./mocks/CurveCrvUSDVaultMock.m.sol";
import {crvUSDToken} from "../src/mocks/core/tokens/crvUSDToken.sol";
import {ERC20Mock} from "../src/mocks/core/tokens/ERC20Mock.sol";
import {ILendingPool} from "../src/interfaces/core/pools/LendingPool/ILendingPool.sol";
import {PercentageMath} from "../src/libraries/math/PercentageMath.sol";
import {WadRayMath} from "../src/libraries/math/WadRayMath.sol";
contract PoolsTest is Test {
using PercentageMath for uint256;
using WadRayMath for uint256;
// contracts
StabilityPool stabilityPool;
LendingPool lendingPool;
DEToken deToken;
RToken rToken;
DebtToken debtToken;
RAACToken raacToken;
RAACMinter raacMinter;
RAACHousePrices raacHousePrices;
RAACNFT raacNft;
CurveCrvUSDVaultMock curveCrvUsdVault;
crvUSDToken crvUsdToken;
ERC20Mock erc20Mock;
// owners
address STABILITY_POOL_OWNER = makeAddr("STABILITY_POOL_OWNER");
address LENDING_POOL_OWNER = makeAddr("LENDING_POOL_OWNER");
address DETOKEN_OWNER = makeAddr("DETOKEN_OWNER");
address RTOKEN_OWNER = makeAddr("RTOKEN_OWNER");
address DEBT_TOKEN_OWNER = makeAddr("DEBT_TOKEN_OWNER");
address RAAC_TOKEN_OWNER = makeAddr("RAAC_TOKEN_OWNER");
address RAAC_MINTER_DUMMY = makeAddr("RAAC_MINTER_DUMMY");
address RAAC_HOUSE_PRICES_OWNER = makeAddr("RAAC_HOUSE_PRICES_OWNER");
address RAAC_HOUSE_PRICES_ORACLE = makeAddr("RAAC_HOUSE_PRICES_ORACLE");
address CRV_USD_TOKEN_OWENR = makeAddr("CRV_USD_TOKEN_OWENR");
address CURVE_CRV_USD_VAULT_OWENR = makeAddr("CURVE_CRV_USD_VAULT_OWENR");
address ERC20_MOCK_TOKEN_OWNER = makeAddr("ERC20_MOCK_TOKEN_OWNER");
address RAAC_NFT_TOKEN_OWNER = makeAddr("RAAC_NFT_TOKEN_OWNER");
address RAAC_MINTER_OWNER = makeAddr("RAAC_MINTER_OWNER");
address RAAC_OWNER = makeAddr("RAAC_OWNER");
// users
address ALICE = makeAddr("ALICE");
address BOB = makeAddr("BOB");
address CHARLIE = makeAddr("CHARLIE");
address DEVIL = makeAddr("DEVIL");
// managers
address MANAGER_1 = makeAddr("MANAGER_1");
address MANAGER_2 = makeAddr("MANAGER_2");
address MANAGER_3 = makeAddr("MANAGER_3");
address MANAGER_4 = makeAddr("MANAGER_4");
address MANAGER_5 = makeAddr("MANAGER_5");
address MANAGER_6 = makeAddr("MANAGER_6");
address MANAGER_7 = makeAddr("MANAGER_7");
address MANAGER_8 = makeAddr("MANAGER_8");
address MANAGER_9 = makeAddr("MANAGER_9");
address MANAGER_10 = makeAddr("MANAGER_10");
uint256 initialPrimeRate = 5e27;
uint256 initialRaacSwapTaxRateInBps = 200; // 2%, 10000 - 100%
uint256 initialRaacBurnTaxRateInBps = 150; // 1.5%, 10000 - 100%
function setUp() public {
vm.warp(block.timestamp + 1 days + 1);
vm.startPrank(CRV_USD_TOKEN_OWENR);
crvUsdToken = new crvUSDToken(CRV_USD_TOKEN_OWENR);
vm.stopPrank();
vm.startPrank(CURVE_CRV_USD_VAULT_OWENR);
curveCrvUsdVault = new CurveCrvUSDVaultMock(address(crvUsdToken), CURVE_CRV_USD_VAULT_OWENR);
vm.stopPrank();
vm.startPrank(RTOKEN_OWNER);
rToken = new RToken("R_Token_V1", "RTKNV1", RTOKEN_OWNER, address(crvUsdToken));
vm.stopPrank();
vm.startPrank(DETOKEN_OWNER);
deToken = new DEToken("DE_Token_V1", "DETKNV1", DETOKEN_OWNER, address(rToken));
vm.stopPrank();
vm.startPrank(DEBT_TOKEN_OWNER);
debtToken = new DebtToken("DEBT_TOKEN_V1", "DEBTKNV1", DEBT_TOKEN_OWNER);
vm.stopPrank();
vm.startPrank(ERC20_MOCK_TOKEN_OWNER);
erc20Mock = new ERC20Mock("ERC20_MOCK_TOKEN", "ERC20MTKN");
vm.stopPrank();
vm.startPrank(RAAC_HOUSE_PRICES_OWNER);
raacHousePrices = new RAACHousePrices(RAAC_HOUSE_PRICES_OWNER);
vm.stopPrank();
vm.startPrank(RAAC_NFT_TOKEN_OWNER);
raacNft = new RAACNFT(address(erc20Mock), address(raacHousePrices), RAAC_NFT_TOKEN_OWNER);
vm.stopPrank();
vm.startPrank(RAAC_OWNER);
raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
vm.stopPrank();
vm.startPrank(LENDING_POOL_OWNER);
lendingPool = new LendingPool(
address(crvUsdToken),
address(rToken),
address(debtToken),
address(raacNft),
address(raacHousePrices),
initialPrimeRate
);
vm.stopPrank();
vm.startPrank(STABILITY_POOL_OWNER);
stabilityPool = new StabilityPool(STABILITY_POOL_OWNER);
vm.stopPrank();
vm.startPrank(RAAC_MINTER_OWNER);
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), RAAC_MINTER_OWNER);
vm.stopPrank();
vm.startPrank(RAAC_OWNER);
raacToken.setMinter(address(raacMinter));
vm.stopPrank();
vm.startPrank(LENDING_POOL_OWNER);
lendingPool.setCurveVault(address(curveCrvUsdVault));
vm.stopPrank();
vm.startPrank(RTOKEN_OWNER);
rToken.setReservePool(address(lendingPool));
vm.stopPrank();
vm.startPrank(DEBT_TOKEN_OWNER);
debtToken.setReservePool(address(lendingPool));
vm.stopPrank();
vm.startPrank(STABILITY_POOL_OWNER);
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(raacMinter),
address(crvUsdToken),
address(lendingPool)
);
vm.stopPrank();
crvUsdToken.mint(address(lendingPool), 1000_000_000e18);
}
function testLendingPoolVerboseReserveStateUpdate() public {
uint256 depositAmount = 1e18;
vm.startPrank(ALICE);
crvUsdToken.mint(ALICE, depositAmount);
vm.stopPrank();
(,,,,,,,, uint256 prevReserveUpdateTimes) = lendingPool.reserve();
(,,,,,,,, uint256 prevRateUpdateTimes) = lendingPool.rateData();
console.log("modified reserve and rate data before update... ");
console.log("reserve update times: ", prevReserveUpdateTimes);
console.log("rate update times: ", prevRateUpdateTimes);
vm.startPrank(ALICE);
crvUsdToken.approve(address(lendingPool), depositAmount);
lendingPool.deposit(depositAmount);
vm.stopPrank();
(,,,,,,,, uint256 currReserveUpdateTimes) = lendingPool.reserve();
(,,,,,,,, uint256 currRateUpdateTimes) = lendingPool.rateData();
console.log("modified reserve and rate data after udpate... ");
console.log("reserve update times: ", currReserveUpdateTimes);
console.log("rate update times: ", currRateUpdateTimes);
// first updateReserveInterests in LiquidityPool::deposit:: (verbose)
// second updateReserveInterests in ReserveLibrary::deposit::
// third updateReserveInterests in ReserveLibrary::deposit::updateInterestRatesAndLiquidity::
assertEq(currReserveUpdateTimes, 3);
assertEq(currRateUpdateTimes, 3);
assertEq(prevReserveUpdateTimes, 0);
assertEq(prevRateUpdateTimes, 0);
assertNotEq(prevReserveUpdateTimes, currReserveUpdateTimes);
assertNotEq(prevRateUpdateTimes, currRateUpdateTimes);
assert(currReserveUpdateTimes > prevReserveUpdateTimes);
assert(currRateUpdateTimes > prevRateUpdateTimes);
}
}

How to Run the Test

  1. Initialize a Foundry Project:

    forge init my-foundry-project
  2. Place Contract Files:
    Ensure that LendingPool.sol and ReserveLibrary.sol are correctly located under src/core/pools/LendingPool/ and src/libraries/pools/ReserveLibrary/ respectively.

  3. Create Test Directory:
    Create a test directory adjacent to src and add the test file (e.g., PoolsTest.t.sol).

  4. Run the Test:

    forge test --mt testLendingPoolVerboseReserveStateUpdate -vv

The test confirms that after a deposit, the update counters have increased by 3, indicating that updateReserveInterests was called three times in the deposit process.

Impact

  • Excessive Gas Consumption:
    Repeated calls to update the reserve state unnecessarily increase gas costs for deposit and withdrawal operations.

  • State Complexity:
    Multiple updates can complicate the understanding and auditing of state changes, potentially leading to discrepancies in reserve calculations.

  • Operational Inefficiency:
    Although the final state remains correct, the protocol wastes computational resources, reducing overall efficiency.

Tools Used

  • Manual Review

  • Foundry

Recommendations

To eliminate redundant calls, streamline the update of reserve interests so that each deposit or withdrawal operation updates the reserve state only once.

Proposed Diff for LendingPool::deposit

Remove the explicit call to ReserveLibrary.updateReserveState from the deposit and withdraw functions, relying on the internal calls within ReserveLibrary.deposit and updateInterestRatesAndLiquidity:

function deposit(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
// Update the reserve state before the deposit
// @info: verbose function already called inside ReserveLibrary.deposit
- ReserveLibrary.updateReserveState(reserve, rateData);
// Perform the deposit through ReserveLibrary
uint256 mintedAmount = ReserveLibrary.deposit(reserve, rateData, amount, msg.sender);
// Rebalance liquidity after deposit
_rebalanceLiquidity();
emit Deposit(msg.sender, amount, mintedAmount);
}
function withdraw(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (withdrawalsPaused) revert WithdrawalsArePaused();
// Update the reserve state before the withdrawal
// @info: verbose function already called inside ReserveLibrary.withdraw
- ReserveLibrary.updateReserveState(reserve, rateData);
// Ensure sufficient liquidity is available
_ensureLiquidity(amount);
// Perform the withdrawal through ReserveLibrary
(uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) = ReserveLibrary.withdraw(
reserve, // ReserveData storage
rateData, // ReserveRateData storage
amount, // Amount to withdraw
msg.sender // Recipient
);
// Rebalance liquidity after withdrawal
_rebalanceLiquidity();
emit Withdraw(msg.sender, amountWithdrawn);
}

Implementing these modifications will reduce unnecessary gas consumption and simplify state management in deposit and withdrawal operations, thereby enhancing the efficiency and maintainability of the lending protocol.

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Appeal created

theirrationalone Submitter
7 months ago
inallhonesty Lead Judge
7 months ago
inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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

Give us feedback!