Core Contracts

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

Outdated Liquidity Index in RToken's `_update` Function Causes Loss of User Funds

Summary

The RToken's _update function, which is called during all token operations, uses an outdated liquidity index from LendingPool when scaling amounts. Since the liquidity index only increases over time and is not updated during transfer operations, users sending tokens lose funds due to incorrect scaling calculations.

Vulnerability Details

The core issue lies in the _update function of RToken, which handles all token operations including transfers. Let's examine the implementation:

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

The problem occurs because:

  1. Every token operation (transfer,transferFrom, mint, burn) calls _update

  2. _update scales the amount using getNormalizedIncome() which returns the current liquidityIndex:

function getNormalizedIncome() external view returns (uint256) {
return reserve.liquidityIndex;
}
  1. For transfer operations, this index is outdated because:

    • No state update is triggered during transfers

    • The index only gets updated during lendingPool operations or directly triger that.

    • The liquidityIndex naturally increases over time with accrued interest

This means when a user transfer amount X , it uses an outdated index to scale the amount to transfer , thus sending more than intended by the user

Example :

User transfer 100 tokens:

  • current outdated index = 1.5

  • actual index should be = 1.6 (after accrued interest)

  • The scaled amount sent = 100/1.5 = 66.67 scaled tokens

  • When it should be = 100/1.6 = 62.5 scaled tokens

  • the user here actually sent (66.67 * 1.6 = 106.67 )to receiver while he intended to send him only 100.

  • this is direct loss of the sender funds.

Note: This primarily affects transfer operations (transfer , transferFrom). Mint and burn operations are safe because they are called through the LendingPool, which updates the index state before calling them often.

PoC

Foundry Envirement Setup
  • i'm using foundry for test , to integrate foundry :
    run :

    npm install --save-dev @nomicfoundation/hardhat-foundry

    add this to hardhat.config.cjs :

    require("@nomicfoundation/hardhat-foundry");

    run :

    npx hardhat init-foundry
  • comment the test/unit/libraries/ReserveLibraryMock.sol as it's causing compiling errors

  • inside test folder , create new dir foundry and inside it , create new file baseTest.sol , and copy/paste this there :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "forge-std/console2.sol";
import "../../contracts/core/tokens/DebtToken.sol";
import "../../contracts/core/tokens/RAACToken.sol";
import "../../contracts/core/tokens/veRAACToken.sol";
import "../../contracts/core/tokens/RToken.sol";
import "../../contracts/core/tokens/DEToken.sol";
import "../../contracts/core/pools/LendingPool/LendingPool.sol";
import "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import "../../contracts/core/tokens/RAACNFT.sol";
import "../../contracts/core/primitives/RAACHousePrices.sol";
import "../../contracts/mocks/core/tokens/crvUSDToken.sol";
import "../../contracts/core/collectors/FeeCollector.sol";
import "../../contracts/core/collectors/Treasury.sol";
import "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import "../../contracts/core/minters/RAACReleaseOrchestrator/RAACReleaseOrchestrator.sol";
contract baseTest is Test {
// Protocol contracts
crvUSDToken public crvUSD;
RAACToken public raacToken;
veRAACToken public veToken;
RAACHousePrices public housePrices;
RAACNFT public raacNFT;
RToken public rToken;
DebtToken public debtToken;
DEToken public deToken;
LendingPool public lendingPool;
StabilityPool public stabilityPool;
Treasury public treasury;
Treasury public repairFund;
FeeCollector public feeCollector;
RAACMinter public minter;
RAACReleaseOrchestrator public releaseOrchestrator;
// Test accounts
address public admin = makeAddr("admin");
address public user1 = makeAddr("user1");
address public user2 = makeAddr("user2");
address public user3 = makeAddr("user3");
// Constants
uint256 public constant INITIAL_MINT = 1000 ether;
uint256 public constant HOUSE_PRICE = 100 ether;
uint256 public constant INITIAL_PRIME_RATE = 0.1e27; // 10% in RAY
uint256 public constant TAX_RATE = 200; // 2% in basis points
uint256 public constant BURN_RATE = 50; // 0.5% in basis points
function setUp() public virtual {
vm.startPrank(admin);
// Deploy base tokens
crvUSD = new crvUSDToken(admin);
crvUSD.setMinter(admin);
raacToken = new RAACToken(admin, TAX_RATE, BURN_RATE);
veToken = new veRAACToken(address(raacToken));
releaseOrchestrator = new RAACReleaseOrchestrator(address(raacToken));
// Deploy mock oracle
housePrices = new RAACHousePrices(admin);
housePrices.setOracle(admin);
// Deploy NFT
raacNFT = new RAACNFT(address(crvUSD), address(housePrices), admin);
// Deploy pool tokens
rToken = new RToken("RToken", "RT", admin, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", admin);
deToken = new DEToken("DEToken", "DEToken", admin, address(rToken));
// Deploy core components
treasury = new Treasury(admin);
repairFund = new Treasury(admin);
feeCollector =
new FeeCollector(address(raacToken), address(veToken), address(treasury), address(repairFund), admin);
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(raacNFT),
address(housePrices),
INITIAL_PRIME_RATE
);
stabilityPool = new StabilityPool(admin);
minter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), address(treasury));
// Initialize contracts
raacToken.setFeeCollector(address(feeCollector));
raacToken.manageWhitelist(address(feeCollector), true);
raacToken.manageWhitelist(address(veToken), true);
raacToken.manageWhitelist(admin, true);
raacToken.setMinter(admin);
raacToken.mint(user2, INITIAL_MINT);
raacToken.mint(user3, INITIAL_MINT);
raacToken.setMinter(address(minter));
bytes32 FEE_MANAGER_ROLE = feeCollector.FEE_MANAGER_ROLE();
bytes32 EMERGENCY_ROLE = feeCollector.EMERGENCY_ROLE();
bytes32 DISTRIBUTOR_ROLE = feeCollector.DISTRIBUTOR_ROLE();
feeCollector.grantRole(FEE_MANAGER_ROLE, admin);
feeCollector.grantRole(EMERGENCY_ROLE, admin);
feeCollector.grantRole(DISTRIBUTOR_ROLE, admin);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
raacToken.transferOwnership(address(minter));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(minter),
address(crvUSD),
address(lendingPool)
);
lendingPool.setStabilityPool(address(stabilityPool));
// Setup test environment
crvUSD.mint(user1, INITIAL_MINT);
crvUSD.mint(user2, INITIAL_MINT);
crvUSD.mint(user3, INITIAL_MINT);
housePrices.setHousePrice(1, HOUSE_PRICE);
vm.stopPrank();
}
// Helper functions
function mintCrvUSD(address to, uint256 amount) public {
vm.prank(admin);
crvUSD.mint(to, amount);
}
function setHousePrice(uint256 tokenId, uint256 price) public {
vm.prank(admin);
housePrices.setHousePrice(tokenId, price);
}
function calculateInterest(uint256 principal, uint256 rate, uint256 time) public pure returns (uint256) {
return principal * rate * time / 365 days / 1e27;
}
function warpAndAccrue(uint256 time) public {
vm.warp(block.timestamp + time);
lendingPool.updateState();
}
}
  • now create a pocs.sol inside test/foundry , and copy/paste this there :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "./baseTest.sol";
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockCurveVault is ERC4626 {
constructor(address _asset) ERC4626(ERC20(_asset)) ERC20("Mock Curve Vault", "mcrvUSD") {}
}
contract pocs is baseTest {
//poc here
}

The following test demonstrates how the outdated index in _update causes users to lose funds during transfers and transfer an amount that is more than intended:

function test_poc06() public {
// deposit some tokens to the pool
vm.startPrank(user1);
crvUSD.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(100 ether);
vm.stopPrank();
// deposit an NFT and borrow tokens
uint256 tokenId = 1;
vm.startPrank(user2);
crvUSD.approve(address(raacNFT), type(uint256).max);
raacNFT.mint(tokenId, 100 ether);
raacNFT.approve(address(lendingPool), tokenId);
lendingPool.depositNFT(tokenId);
lendingPool.borrow(100 ether);
vm.stopPrank();
// accrue interest over one year
vm.warp(block.timestamp + 365 days);
// user transfer : 50 ether token to receiver :
address receiver = makeAddr("receiver");
vm.startPrank(user1);
lendingPool.rToken().transfer(receiver, 50 ether);
vm.stopPrank();
// the reciever should actualy get a balance of : 50
console.log("receiver balance before updating the state: ", lendingPool.rToken().balanceOf(receiver) / 1e18);
lendingPool.updateState();
// however he actually get 70 :
console.log("receiver balance after updating the state: ", lendingPool.rToken().balanceOf(receiver) / 1e18);
}

Running this test shows:

[PASS] test_poc06() (gas: 717653)
Logs:
receiver balance before updating the state: 50
receiver balance after updating the state: 70

Impact

  • Users performing transfers lose funds due to sending more scaled tokens than intended

  • The loss increases the longer the index hasn't been updated

  • Every transfer operation is affected, making this a systematic issue

  • Direct economic impact on users

Tools Used

  • Manual Review

  • Foundry

Recommendations

Update state before transfers:

function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
+ ILendingPool(_reservePool).updateState();
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 6 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.