Core Contracts

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

Double-Scaled Transfer Issue in RToken

01. Relevant GitHub Links

02. Summary

The RToken contract applies scaling twice when a user calls transfer or transferFrom. Since the _update function already handles scaling, the additional scaling in transfer and transferFrom causes the actual transferred amount to be lower than expected. As time passes and getNormalizedIncome grows, the gap becomes larger.

03. Vulnerability Details

RToken::transfer calculates scaledAmount by dividing the user’s desired transfer amount by getNormalizedIncome.

/**
* @dev Overrides the ERC20 transfer function to use scaled amounts
* @param recipient The recipient address
* @param amount The amount to transfer (in underlying asset units)
*/
function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}

However, _update also scales amount internally, causing a double scaling effect. The same bug occurs in transferFrom, leading to a significant under-transfer of tokens over time.

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

04. Impact

When users transfer or transferFrom their RToken balances, they lose a portion of the intended transfer amount. As time goes on and getNormalizedIncome increases, the discrepancy in the transferred amount also grows, potentially leading to a significant loss.

05. Proof of Concept

Running forge test –mt test_poc_transfer -vv shows that Alice tries to transfer 50e18 to Bob, but only 100352011494252873563 is actually sent. The logs confirm that Alice’s balance remains higher than expected, while Bob’s balance is lower than intended.

[PASS] test_transfer() (gas: 659079)
Logs:
rTokenInstance.balanceOf(alice): 4022988505747126437
rTokenInstance.balanceOf(bob): 100352011494252873563
// 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() public {
// 1. bob deposit, depositNFT, borrow, repay
// (Many people have interacted with lendingPool)
vm.startPrank(bob);
crvUSDTokenInstance.approve(address(lendingPoolInstance), 500e18);
lendingPoolInstance.deposit(50e18);
raacNFTInstance.approve(address(lendingPoolInstance), 1);
lendingPoolInstance.depositNFT(1);
lendingPoolInstance.borrow(10e18);
vm.warp(block.timestamp + 365 days * 10);
crvUSDTokenInstance.approve(address(lendingPoolInstance), type(uint256).max);
lendingPoolInstance.repay(type(uint256).max);
vm.stopPrank();
// 1. alice deposit 50e18
// However, fewer assets are sent than expected.
// This gap widens as more time passes.
vm.startPrank(alice);
crvUSDTokenInstance.approve(address(lendingPoolInstance), 50e18);
lendingPoolInstance.deposit(50e18);
assertEq(rTokenInstance.balanceOf(alice), 50e18);
rTokenInstance.transfer(bob, 50e18);
console.log("rTokenInstance.balanceOf(alice): ", rTokenInstance.balanceOf(alice));
console.log("rTokenInstance.balanceOf(bob): ", rTokenInstance.balanceOf(bob));
}
}

06. Tools Used

Manual Code Review and Foundry

07. Recommended Mitigation

/**
* @dev Overrides the ERC20 transfer function to use scaled amounts
* @param recipient The recipient address
* @param amount The amount to transfer (in underlying asset units)
*/
function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
- uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}
Updates

Lead Judging Commences

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

RToken::transfer and transferFrom double-scale amounts by dividing in both external functions and _update, causing users to transfer significantly less than intended

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

RToken::transfer and transferFrom double-scale amounts by dividing in both external functions and _update, causing users to transfer significantly less than intended

Support

FAQs

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