Core Contracts

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

Transferring rToken devalues it via double scaling

Summary

The RToken implementation suffers from a double-scaling issue during token transfers, where the transfer amount is scaled twice: once in the RToken::transfer function and again in the RToken::_update function. This results in the transferred tokens being devalued unnecessarily, causing incorrect token balances and potential financial losses for users.

Vulnerability Details

When a user deposits assets via LendingPool::deposit, they are minted rTokens. See below:

/**
* @notice Allows a user to deposit reserve assets and receive RTokens
* @param amount The amount of reserve assets to deposit
*/
function deposit(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
// Update the reserve state before the deposit
ReserveLibrary.updateReserveState(reserve, rateData);
// Perform the deposit through ReserveLibrary
uint256 mintedAmount = ReserveLibrary.deposit(reserve, rateData, amount, msg.sender);
// Rebalance liquidity after deposit
_rebalanceLiquidity();
emit Deposit(msg.sender, amount, mintedAmount);
}
/**
* @notice Handles deposit operation into the reserve.
* @dev Transfers the underlying asset from the depositor to the reserve, and mints RTokens to the depositor.
* This function assumes interactions with ERC20 before updating the reserve state (you send before we update how much you sent).
* A untrusted ERC20's modified mint function calling back into this library will cause incorrect reserve state updates.
* Implementing contracts need to ensure reentrancy guards are in place when interacting with this library.
* @param reserve The reserve data.
* @param rateData The reserve rate parameters.
* @param amount The amount to deposit.
* @param depositor The address of the depositor.
* @return amountMinted The amount of RTokens minted.
*/
function deposit(
ReserveData storage reserve,
ReserveRateData storage rateData,
uint256 amount,
address depositor
) internal returns (uint256 amountMinted) {
if (amount < 1) revert InvalidAmount();
// Update reserve interests
updateReserveInterests(reserve, rateData);
// Transfer asset from caller to the RToken contract
IERC20(reserve.reserveAssetAddress).safeTransferFrom(
msg.sender, // from
reserve.reserveRTokenAddress, // to
amount // amount
);
// Mint RToken to the depositor (scaling handled inside RToken)
(
bool isFirstMint,
uint256 amountScaled,
uint256 newTotalSupply,
uint256 amountUnderlying
) = IRToken(reserve.reserveRTokenAddress).mint(
address(this), // caller
depositor, // onBehalfOf
amount, // amount
reserve.liquidityIndex // index
);
amountMinted = amountScaled;
// Update the total liquidity and interest rates
updateInterestRatesAndLiquidity(reserve, rateData, amount, 0);
emit Deposit(depositor, amount, amountMinted);
return amountMinted;
}

The amount of rTokens minted to the user are normalized to allow for value accrual as the liquidity index increases. This is done in RToken::_update which overides the normal ERC20 _update function. See below:

/**
* @dev Internal function to handle token transfers, mints, and burns
* @param from The sender address
* @param to The recipient address
* @param amount The amount of tokens
*/
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);
}

The issue occurs when a user attempts to transfer tokens to another user, the amount is being scaled twice before it is being sent which devalues the rtokens unneccesarily. It is scaled once in RToken:: _update and again in RToken::transfer See below:

/**
* @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);
}

As a result, the transferred tokens aren't reflected in the receiver's balance.

Proof Of Code (POC)

The following test was run in LendingPool.test.js in the "Borrow and Repay" describe block

it("transfering rtokens devalues them via double scaling issue", async function () {
//c for testing purposes
const reserve = await lendingPool.reserve();
console.log("reserve", reserve.lastUpdateTimestamp);
//c first borrow that updates liquidity index and interest rates as deposits dont update it
const depositAmount = ethers.parseEther("100");
await lendingPool.connect(user1).borrow(depositAmount);
await time.increase(5000 * 24 * 60 * 60);
await ethers.provider.send("evm_mine");
//c user deposits tokens into lending pool to get rtokens
await lendingPool.connect(user1).deposit(depositAmount);
const reservedata = await lendingPool.getAllUserData(user1.address);
console.log(`liqindex`, reservedata.liquidityIndex);
//c get scaled rtokenbalance of user1
const user1RTokenBalance = await rToken.scaledBalanceOf(user1.address);
console.log("user1RTokenBalance", user1RTokenBalance);
//c get user2 token balance before transfer
const pretransferuser2RTokenBalance = await rToken.scaledBalanceOf(
user2.address
);
console.log(
"pretransferuser2RTokenBalance",
pretransferuser2RTokenBalance
);
//c proof that rtokens are scaled upon minting
assert(user1RTokenBalance < depositAmount);
const transferAmount = ethers.parseEther("50");
//c user transfers rtoken to user2
await rToken.connect(user1).transfer(user2.address, transferAmount);
//c get rtokenbalance of user2
const user2RTokenBalance = await rToken.scaledBalanceOf(user2.address);
console.log("user2RTokenBalance", user2RTokenBalance);
//c get amount transferred to user 2
const amountTransferred =
user2RTokenBalance - pretransferuser2RTokenBalance;
console.log("amountTransferred", amountTransferred);
//c single scaled transfer amount
/*c IMPORTANT: for this test to work, first go to reservelibrarymock.sol and include the following functions:
function raymul(
uint256 val1,
uint256 val2
) external pure returns (uint256) {
return val1.rayMul(val2);
}
function raydiv(
uint256 val1,
uint256 val2
) external pure returns (uint256) {
return val1.rayDiv(val2);
}
then deploy the contract with the following lines:
const reservelibrary = await ethers.getContractFactory(
"ReserveLibraryMock"
);
reserveLibrary = await reservelibrary.deploy();
*/
const normalizedincome = await lendingPool.getNormalizedIncome();
console.log("normalizedincome", normalizedincome);
const singlescaledtamount = await reserveLibrary.raydiv(
transferAmount,
normalizedincome
);
const amount1 = await rToken.amount1();
console.log("amount1", amount1);
//c proof that rtokens are double scaled upon transfer further devauling them
assert(amountTransferred < singlescaledtamount);
//c when the amount is transferred to the user, the actual amount does not equal the amount transferred which shows that the rtokens are double scaled
const amounttransferredunscaled = await reserveLibrary.raymul(
amountTransferred,
normalizedincome
);
assert(amounttransferredunscaled < transferAmount);
});

Impact

Financial Loss: Users lose value when transferring rTokens, as the transferred amount is devalued due to double-scaling.

Incorrect Balances: Token balances for both the sender and recipient are incorrect, leading to accounting discrepancies.

Tools Used

Manual Review, Hardhat

Recommendations

To fix this issue, the double-scaling should be eliminated by ensuring the transfer amount is scaled only once. This can be achieved by modifying the RToken::transfer function to avoid scaling the amount before passing it to super.transfer.

Updated RToken::transfer Function
Remove the scaling logic from the transfer function, as scaling is already handled in _update:

/**
* @dev Overrides the ERC20 transfer function to use unscaled 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) {
// Do not scale the amount here; scaling is handled in _update
return super.transfer(recipient, amount);
}

The transfer function now passes the unscaled amount to super.transfer. The _update function will handle the scaling of the amount, ensuring it is scaled only once. This ensures that the transferred amount is correctly reflected in the recipient's balance without unnecessary devaluation.

Updates

Lead Judging Commences

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