Core Contracts

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

Double Scaling in Transfer Operations

Summary

The RToken contract has a critical double scaling vulnerability in its transfer implementation due to multiple scaling operations being applied to the same amount.

https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/tokens/RToken.sol#L213
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/tokens/RToken.sol#L309

Vulnerability Details

The double scaling occurs because:

  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);
}
  1. Second scaling in _update:

function _update(address from, address to, uint256 amount) internal override {
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
super._update(from, to, scaledAmount);
}

Impact

  • When a user tries to transfer X tokens, the actual amount transferred will be X/(index²) instead of X/index

  • This means users will transfer much smaller amounts than intended

Proof of concept

const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Double Scaling PoC", function () {
let rToken, mockLendingPool, owner, user1, user2;
const RAY = ethers.BigNumber.from("1000000000000000000000000000"); // 1e27
beforeEach(async function () {
[owner, user1, user2] = await ethers.getSigners();
// Deploy a mock LendingPool with a settable normalized income
const MockLendingPool = await ethers.getContractFactory("MockLendingPool");
mockLendingPool = await MockLendingPool.deploy();
await mockLendingPool.deployed();
// Set normalized income to 2*RAY to simulate scaling
await mockLendingPool.setNormalizedIncome(RAY.mul(2));
// Deploy RToken contract (using owner's address as initialOwner and dummy asset)
const RTokenFactory = await ethers.getContractFactory("RToken");
rToken = await RTokenFactory.deploy("RToken", "RT", owner.address, owner.address);
await rToken.deployed();
// Set the Reserve Pool to the mock LendingPool
await rToken.setReservePool(mockLendingPool.address);
// --- Setup: Mint tokens to user1 via the RToken.mint function ---
// For PoC we simulate a mint call (onlyReservePool) by using owner's address as caller
// Note: In a real setup, the reserve pool would call mint.
const amountUnderlying = ethers.utils.parseEther("1000");
await rToken.mint(owner.address, user1.address, amountUnderlying, RAY);
});
it("demonstrates that transfer uses double scaling", async function () {
// User1 transfers 500 underlying tokens to user2
const transferAmount = ethers.utils.parseEther("500");
await rToken.connect(user1).transfer(user2.address, transferAmount);
// Check the balance of user2
const user2Balance = await rToken.balanceOf(user2.address);
console.log("User2 scaled balance:", user2Balance.toString());
// Expected behavior (if single scaling) would be about 500 tokens (scaled).
// With double scaling, the balance is significantly lower.
// For example, with normalized income = 2*RAY:
// Scaling in transfer: 500 / 2 = 250 tokens passed to _update.
// If double scaling were applied, _update would rescale again.
// Adjust the expected value accordingly to observe the issue.
expect(user2Balance.lt(transferAmount)).to.be.true;
});
});
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

Support

FAQs

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

Give us feedback!