Core Contracts

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

Outdated NormalizedIncome in RToken Transfer Functions

01. Relevant GitHub Links

02. Summary

When transferring RToken (or using functions like transfer, transferFrom, approve, permit) that rely on ILendingPool(_reservePool).getNormalizedIncome(), the value obtained for NormalizedIncome can be outdated. If pool.updateState() has not been recently called or if there has been no protocol interaction for a while, transferring tokens can result in a transfer of more tokens than intended.

03. Vulnerability Details

RToken uses the function ILendingPool(_reservePool).getNormalizedIncome() to scale the amount parameter in its transfer-related methods:

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

If getNormalizedIncome() is outdated because pool.updateState() was not called for a while, the user might transfer more tokens than they intended. This issue applies to any function using the same approach, including transferFrom, approve, and permit.

04. Impact

  • Users can inadvertently transfer more tokens than expected.

  • The system’s internal accounting may become inconsistent if the NormalizedIncome used for calculations is not refreshed.

05. Proof of Concept

you can see that calling transfer without updating the pool state can lead to an actual transfer amount exceeding the expected 10e18 tokens. The reason why exactly 10e18 is not transferred after the actual updateState is due to a bad implementation of the transfer function, which we'll cover in another report.

$ forge test --mt test_poc_transfer_need_updateState -vv
Logs:
no updateState before transfer()
rTokenInstance.balanceOf(alice): 40019421487603305785
rTokenInstance.balanceOf(hyuunn): 10004855371900826446
updateState before transfer()
rTokenInstance.balanceOf(alice): 40029129875085409916
rTokenInstance.balanceOf(hyuunn): 9995146984418722315
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {crvUSDToken} from "src/mocks/core/tokens/crvUSDToken.sol";
import {RAACHousePrices} from "src/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "src/core/tokens/RAACNFT.sol";
import {IRToken, RToken} from "src/core/tokens/RToken.sol";
import {DebtToken} from "src/core/tokens/DebtToken.sol";
import {LendingPool} from "src/core/pools/LendingPool/LendingPool.sol";
import {ReserveLibrary} from "src/libraries/pools/ReserveLibrary.sol";
contract BaseTest is Test {
crvUSDToken public crvUSDTokenInstance;
RAACHousePrices public raacHousePricesInstance;
RAACNFT public raacNFTInstance;
RToken public rTokenInstance;
DebtToken public debtTokenInstance;
LendingPool public lendingPoolInstance;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address hyuunn = makeAddr("hyuunn");
function setUp() public {
// crvUSDToken deploy
crvUSDTokenInstance = new crvUSDToken(address(this));
// raacHousePrices deploy
raacHousePricesInstance = new RAACHousePrices(address(this));
raacHousePricesInstance.setOracle(address(this));
// raacNFT deploy
raacNFTInstance = new RAACNFT(
address(crvUSDTokenInstance),
address(raacHousePricesInstance),
address(this)
);
_mintRaacNFT();
rTokenInstance = new RToken(
"RToken",
"RTK",
address(this),
address(crvUSDTokenInstance)
);
debtTokenInstance = new DebtToken("DebtToken", "DEBT", address(this));
lendingPoolInstance = new LendingPool(
address(crvUSDTokenInstance),
address(rTokenInstance),
address(debtTokenInstance),
address(raacNFTInstance),
address(raacHousePricesInstance),
0.1e27
);
rTokenInstance.setReservePool(address(lendingPoolInstance));
debtTokenInstance.setReservePool(address(lendingPoolInstance));
}
function _mintRaacNFT() internal {
// housePrices setting
raacHousePricesInstance.setHousePrice(0, 100e18);
raacHousePricesInstance.setHousePrice(1, 50e18);
raacHousePricesInstance.setHousePrice(2, 150e18);
// crvUSDToken mint
deal(address(crvUSDTokenInstance), alice, 1000e18);
deal(address(crvUSDTokenInstance), bob, 1000e18);
deal(address(crvUSDTokenInstance), hyuunn, 1000e18);
// raacNFT mint
vm.startPrank(alice);
crvUSDTokenInstance.approve(address(raacNFTInstance), 100e18 + 1);
raacNFTInstance.mint(0, 100e18 + 1);
vm.stopPrank();
vm.startPrank(bob);
crvUSDTokenInstance.approve(address(raacNFTInstance), 50e18 + 1);
raacNFTInstance.mint(1, 50e18 + 1);
vm.stopPrank();
}
function test_poc_transfer_need_updateState() public {
// 1. bob deposit, depositNFT, borrow
vm.startPrank(bob);
crvUSDTokenInstance.approve(address(lendingPoolInstance), 500e18);
lendingPoolInstance.deposit(500e18);
raacNFTInstance.approve(address(lendingPoolInstance), 1);
lendingPoolInstance.depositNFT(1);
lendingPoolInstance.borrow(10e18);
vm.stopPrank();
// 2. alice deposit 50e18
vm.startPrank(alice);
crvUSDTokenInstance.approve(address(lendingPoolInstance), 50e18);
lendingPoolInstance.deposit(50e18);
// 3. Time passes without the state of the pool being updated.
vm.warp(block.timestamp + 365 days);
uint256 snapshot = vm.snapshotState();
// 4. alice tries to transfer 50e18 to hyuunn.
rTokenInstance.transfer(hyuunn, 10e18);
lendingPoolInstance.updateState();
console.log("\\nno updateState before transfer()");
console.log("rTokenInstance.balanceOf(alice): ", rTokenInstance.balanceOf(alice));
console.log("rTokenInstance.balanceOf(hyuunn): ", rTokenInstance.balanceOf(hyuunn));
vm.revertToState(snapshot);
lendingPoolInstance.updateState();
rTokenInstance.transfer(hyuunn, 10e18);
console.log("\\nupdateState before transfer()");
console.log("rTokenInstance.balanceOf(alice): ", rTokenInstance.balanceOf(alice));
console.log("rTokenInstance.balanceOf(hyuunn): ", rTokenInstance.balanceOf(hyuunn));
}
}

06. Tools Used

Manual Code Review and Foundry

07. Recommended Mitigation

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

Perform updateState before performing any transfer-related logic functions.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 month 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.