Core Contracts

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

Multiple Scaling in RToken Transfers Due to Solidity C3 Linearization

Summary

The RToken contract suffers from a double scaling issue in its transfer and transferFrom functions due to Solidity's C3 linearization (Method Resolution Order ) here . The amount is scaled down twice: once in the transfer functions and again in the overridden _update function, leading to incorrect token transfers.

Vulnerability Details

Source

The issue occurs because of multiple scaling operations:

  1. First scaling in transfer/transferFrom:

function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}
function transferFrom(address sender, address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
uint256 scaledAmount = amount.rayDiv(_liquidityIndex);
return super.transferFrom(sender, recipient, scaledAmount);
}
  1. Second scaling in _update:

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

Proof of Concept

Set up foundry in hardhat here

Foundry Test

// SPDX-License-Identifier: MIT
import {Test} from "forge-std/Test.sol";
import {RToken} from "../contracts/core/tokens/RToken.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {console} from "forge-std/console.sol";
contract MockLendingPool {
function getNormalizedIncome() external pure returns (uint256) {
return 1.1e27; // Setting the Liquidity index to 1.1 in ray
}
}
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {
_mint(msg.sender, 1000000e18); // Mint initial supply
}
}
contract RTokenPoc is Test {
using WadRayMath for uint256;
RToken rToken;
address owner;
address user1;
address user2;
ERC20 assestaddress;
MockLendingPool lendingPool;
function setUp() public {
lendingPool = new MockLendingPool();
assestaddress = new MockERC20("testassest", "TAA");
owner = address(this);
user1 = makeAddr("user1");
user2 = makeAddr("user2");
rToken = new RToken("RToken", "RT", owner, address(assestaddress));
rToken.setReservePool(address(lendingPool));
vm.prank(address(lendingPool));
rToken.mint(address(lendingPool), user1, 1000e18, WadRayMath.RAY);
}
function test_transfer_multiple_scaling() public {
uint256 amount = 1000e18;
// Initial balance check
console.log("Initial balance (normalized) of user1: %s", rToken.balanceOf(user1));
console.log("Initial scaled balance of user1: %s", rToken.scaledBalanceOf(user1));
// Perform transfer
vm.prank(user1);
rToken.transfer(user2, amount);
// Balance after transfer should be amount, but it's actually amount/1.1/1.1
console.log("Final balance (normalized) of user2: %s", rToken.balanceOf(user2));
console.log("Final scaled balance of user2: %s", rToken.scaledBalanceOf(user2));
// This assertion will fail because of double scaling
assert(rToken.balanceOf(user2) == amount);
}
function test_transferFrom_multiple_scaling() public {
uint256 amount = 1000e18;
// Initial balance check
console.log("Initial balance (normalized) of user1: %s", rToken.balanceOf(user1));
console.log("Initial scaled balance of user1: %s", rToken.scaledBalanceOf(user1));
vm.prank(address(lendingPool));
rToken.updateLiquidityIndex(1.1e27);
// Approve and transfer
vm.prank(user1);
rToken.approve(user2, amount);
vm.prank(user2);
rToken.transferFrom(user1, user2, amount);
// Balance after transfer should be amount, but it's actually amount/1.1/1.1
console.log("Final balance (normalized) of user2: %s", rToken.balanceOf(user2));
console.log("Final scaled balance of user2: %s", rToken.scaledBalanceOf(user2));
// This assertion will fail because of double scaling
assert(rToken.balanceOf(user2) == amount);
}
}

Mathematical Proof

For a transfer of 1000 tokens with liquidityIndex = 1.1:

  1. First scaling in transfer():

    scaledAmount = 1000 / 1.1 = 909.09
  2. Second scaling in _update():

    scaledAmount = 909.09 / 1.1 = 826.45
  3. Result: User receives 826.45 tokens instead of 909.09tokens

Impact

  • Users receive fewer tokens than intended in transfers

  • Incorrect token accounting throughout the system

  • Potential loss of funds for users

Tools Used

  • Manual code review

Recommendations

  1. Remove scaling from transfer/transferFrom and keep it only in _update:

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

Lead Judging Commences

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

Give us feedback!