Core Contracts

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

Double Scaling in `RToken` Transfer Operations Leads to Significant Token Loss

Summary

The RToken contract's transfer mechanism incorrectly applies scaling twice to transfer amounts. First scaling occurs in the transfer/transferFrom functions, and a second scaling happens in the overridden _update function. This double scaling results in users receiving significantly fewer tokens than intended (amount/(index²) instead of amount/index), leading to substantial loss of user funds and broken token accounting.

Vulnerability Details

The vulnerability occurs in the interaction between RToken's transfer functions and the ERC20 internal _update function. Here's the problematic flow:

  1. User calls transfer with amount X:

// RToken.sol
function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}
  1. The super.transfer call leads to ERC20's _transfer, which then calls RToken's overridden _update:

The call flow:

transfer(1000)
-> scaledAmount = 1000/index
-> super.transfer(1000/index)
-> _update(1000/index)
-> (1000/index)/index = 1000/(index²)

This means if a user tries to transfer 1000 tokens when the index is 2 :

  • receiver exepected: 1000/2 = 500 scaled tokens

  • Actual: 1000/(2*2) = 250 scaled tokens

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
}

A POC demonstrates how the double scaling can lead to token loss :

function test_poc05() 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);
lendingPool.updateState();
lendingPool.getUserDebt(user2);
// Capture user3's initial rToken balance
uint256 initialBalance = lendingPool.rToken().balanceOf(user3);
// Perform the transfer from user1 to user3; note that for transfer the token logic applies double scaling
uint256 user1BalBefore = lendingPool.rToken().balanceOf(user1) / 1e18;
console.log("user1 balance before", user1BalBefore);
vm.startPrank(user1);
bool success = lendingPool.rToken().transfer(user3, 60 ether);
require(success, "Transfer failed");
vm.stopPrank();
// Capture the new balance and calculate the received amount
uint256 finalBalance = lendingPool.rToken().balanceOf(user3);
uint256 received = finalBalance - initialBalance;
console.log(
"user1 balance after",
lendingPool.rToken().balanceOf(user1) / 1e18,
"while it should be ",
user1BalBefore - 60
);
console.log("User3 received:", received / 1e18, "instead of ", 60);
// Verify that the received amount is less than 10 ether due to mis-scaling
assertLt(received, 60 ether);
}
  • logs :

[PASS] test_poc05() (gas: 728150)
Logs:
user1 balance before 140
user1 balance after 97 while it should be 80
User3 received: 42 instead of 60

Impact

  • Every transfer operation results in users receiving 1/index fewer tokens than intended

  • As the index grows over time with accrued interest, the impact becomes more severe

  • Protocol's token accounting system becomes fundamentally broken

  • High severity due to direct loss of user funds and broken core functionality

Tools Used

  • Manual Review

  • Foundry

Recommendations

Remove the scaling operation from the transfer functions , remove both transfer and transferFrom functions , the function in the inherited ERC20 are operating correctly .

// RToken.sol
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 6 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 6 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.