Core Contracts

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

Reserve indices won't be updated on successive transactions in the same block

Summary

Usage and liquidity indices are supposed to be updated on every deposit and borrow but this fails to happen if there are multiple of these function calls in the same block. This results in the indices becoming stale.

Vulnerability Details

At the beginning of each method that applies state changes in the LendingPool, ReserveLibrary::updateReserveInterests() is called which sets reserve.lastUpdateTimestamp to the current block.timestamp. After the state changes have been applied ReserveLibrary::updateReserveInterests() is called again to update the indices, but fails to do so because of this check triggering an early return:

uint256 timeDelta = block.timestamp - uint256(reserve.lastUpdateTimestamp);
if(timeDelta < 1) {
return;
}

Impact

The indices will be updated only on the first borrow/deposit/repay in the block, with each consecutive one working with stale data.

POC

set up:

  1. run npm install and add .env file if you haven't already

  2. npm i --save-dev @nomicfoundation/hardhat-foundry - Install the hardhat-foundry plugin.

  3. Add require("@nomicfoundation/hardhat-foundry"); to the top of your hardhat.config.js file.

  4. Run npx hardhat init-foundry in your terminal. This will generate a foundry.toml file based on your Hardhat project’s existing configuration, and will install the forge-std library.

  5. mkdir test/foundry

  6. Create *.t.sol file inside /test/foundry and paste the POC inside.

  7. In foundry.toml update test = 'test/foundry'

running test:

forge test --match-test testReserveIndicesNotUpdatedInSameBlock -vv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "../../contracts/core/pools/LendingPool/LendingPool.sol";
import "../../contracts/libraries/pools/ReserveLibrary.sol";
import "../../lib/forge-std/src/Test.sol";
import "../../lib/forge-std/src/console.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "../../contracts/core/tokens/DebtToken.sol";
import "../../contracts/core/tokens/RToken.sol";
import "../../contracts/core/tokens/RAACNFT.sol";
import "../../contracts/core/primitives/RAACHousePrices.sol";
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function balanceOf(address account) public view override returns (uint256) {
return super.balanceOf(account);
}
}
contract ReserveLibraryTest is Test {
//address that deploys and owns contracts
address owner;
address user;
LendingPool lendingPool;
MockERC20 reserveAsset;
RToken rToken;
DebtToken debtToken;
RAACNFT raacNFt;
address priceOracle;
RAACHousePrices housePrices;
ReserveLibrary.ReserveData reserve;
ReserveLibrary.ReserveRateData rateData;
function setUp() public {
owner = makeAddr("owner");
initializeContracts(owner);
configureContracts(owner);
user = makeAddr("user");
}
function initializeContracts(address _owner) internal {
reserveAsset = new MockERC20("reserveMock", "mkTkn");
rToken = new RToken("rMock", "mkTkn", _owner, address(reserveAsset));
debtToken = new DebtToken("debtMock", "mkTkn", _owner);
priceOracle = makeAddr("priceOracle");
housePrices = new RAACHousePrices(_owner);
raacNFt = new RAACNFT(address(reserveAsset), address(housePrices), _owner);
lendingPool = new LendingPool(
address(reserveAsset), address(rToken), address(debtToken), address(raacNFt), address(housePrices), 1e26
);
}
function configureContracts(address _owner) internal {
vm.startPrank(_owner);
housePrices.setOracle(priceOracle);
debtToken.setReservePool(address(lendingPool));
rToken.setReservePool(address(lendingPool));
vm.stopPrank();
vm.prank(priceOracle);
housePrices.setHousePrice(1, 10_000);
}
function updateReserveStates() public {
(
address reserveRTokenAddress,
address reserveAssetAddress,
address reserveDebtTokenAddress,
uint256 totalLiquidity,
uint256 totalUsage,
uint128 liquidityIndex,
uint128 usageIndex,
uint40 lastUpdateTimestamp
) = lendingPool.reserve();
reserve = ReserveLibrary.ReserveData(
reserveRTokenAddress,
reserveAssetAddress,
reserveDebtTokenAddress,
totalLiquidity,
totalUsage,
liquidityIndex,
usageIndex,
lastUpdateTimestamp
);
(
uint256 currentLiquidityRate,
uint256 currentUsageRate,
uint256 primeRate,
uint256 baseRate,
uint256 optimalRate,
uint256 maxRate,
uint256 optimalUtilizationRate,
uint256 protocolFeeRate
) = lendingPool.rateData();
rateData = ReserveLibrary.ReserveRateData(
currentLiquidityRate,
currentUsageRate,
primeRate,
baseRate,
optimalRate,
maxRate,
optimalUtilizationRate,
protocolFeeRate
);
}
function testReserveIndicesNotUpdatedInSameBlock() public {
uint256 amount = 1e18;
address borrower = makeAddr("borrower");
//starting liquidity index = 1e27
updateReserveStates();
uint256 initialLiquidityIndex = reserve.liquidityIndex;
uint256 initialUsageIndex = reserve.usageIndex;
console.log("initial liquidity index: %d", initialLiquidityIndex);
console.log("initial usage index: %d", initialUsageIndex);
//mint reserve asset
reserveAsset.mint(user, 20_000);
reserveAsset.mint(borrower, 10_000);
//deposit liquidity into the lending pool that will be available for borrow
vm.startPrank(user);
deposit(10_000);
vm.stopPrank();
//get liquidity index after first deposit
updateReserveStates();
uint256 liquidityIndexAfterFirstDeposit = reserve.liquidityIndex;
console.log("liquidity index after first deposit: %d", liquidityIndexAfterFirstDeposit);
//assert that the liquidity index hasn't been updated after the first deposit
assertEq(initialLiquidityIndex, liquidityIndexAfterFirstDeposit);
//borrow so that the utilization rate is not zero
vm.startPrank(borrower);
mintNFT();
depositNFT();
lendingPool.borrow(1000);
vm.stopPrank();
//assert that the usage index hasn't been updated after the borrow
updateReserveStates();
assertEq(initialUsageIndex, reserve.usageIndex);
//deposit liquidity into the lending pool to see if liquidity index updates
vm.startPrank(user);
deposit(10_000);
vm.stopPrank();
//get liquidity index after second deposit
updateReserveStates();
uint256 liquidityIndexAfterSecondDeposit = reserve.liquidityIndex;
console.log("liquidity index after second deposit: %d", liquidityIndexAfterSecondDeposit);
//assert that the liquidity index hasn't been updated after the second deposit
assertEq(liquidityIndexAfterFirstDeposit, liquidityIndexAfterSecondDeposit);
}
function mintNFT() internal {
reserveAsset.approve(address(raacNFt), 10_000);
raacNFt.mint(1, 10_000);
}
function depositNFT() internal {
raacNFt.approve(address(lendingPool), 1);
lendingPool.depositNFT(1);
}
function deposit(uint256 amount) internal {
reserveAsset.approve(address(lendingPool), amount);
lendingPool.deposit(amount);
}
}

Tools Used

Manual review

Recommendations

Consider creating a second method for updating the indices that doesn't perform the time delta check (forces update) and use it after any reserve state updating logic while keeping the current logic for refreshing indices before any reserve state changes.

Updates

Lead Judging Commences

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

ReserveLibrary::updateReserveInterests skips updates when called multiple times in the same block due to timeDelta check, causing stale indices and incorrect interest calculations

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

ReserveLibrary::updateReserveInterests skips updates when called multiple times in the same block due to timeDelta check, causing stale indices and incorrect interest calculations

Appeal created

anonymousjoe Auditor
3 months ago
inallhonesty Lead Judge
3 months ago
inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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