Core Contracts

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

Incorrect Transfer Amount Due to Double Scaling in RToken

[H-01] Incorrect Transfer Amount Due to Double Scaling in RToken

Summary

RToken, similar to Aave’s aTokens, applies a liquidity index to scale balances dynamically. However, a bug in the transfer and transferFrom functions causes the amount to be scaled twice due to incorrect handling of the _update function. This results in users transferring incorrect token amounts, leading to discrepancies in balances.

Vulnerability Details

The issue stems from how the transfer functions process the scaling conversion. Instead of converting the amount once, it is applied both inside the transfer function and again inside _update, leading to incorrect values.

Issue in Transfer Functions

function transfer(
address recipient,
uint256 amount
) public override(ERC20, IERC20) returns (bool) {
uint256 scaledAmount = amount.rayDiv(
ILendingPool(_reservePool).getNormalizedIncome()
);
return super.transfer(recipient, scaledAmount); // Incorrect: Scaling applied here
}
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); // Incorrect: Scaling applied here
}

Issue in _update Function

function _update(
address from,
address to,
uint256 amount
) internal override {
// Scale amount by normalized income
uint256 scaledAmount = amount.rayDiv(
ILendingPool(_reservePool).getNormalizedIncome()
);
super._update(from, to, scaledAmount); // Scaling happens again
}

Impact

  • Users Transfer Incorrect Amounts: If a user attempts to send their entire balance, they may end up transferring less than intended.

  • Residual Balance Left Behind: Since the transfer amount gets reduced due to double scaling, the sender may still have a small balance left.

  • Mismatch Between Expected and Actual Balances: Users expecting to receive an exact amount will instead receive less than expected.

Why Was This Not Caught in Tests?

  • The default liquidity index in tests is 1e27 (RAY), meaning scaling had no visible effect.

  • The bug only becomes apparent when the liquidity index is dynamically updated over time, reflecting real-world conditions.

Proof of Concept

Modify the should allow transfers between users test case to include a change in liquidity index:

it("should allow transfers between users", async function () {
const mintAmount = ethers.parseEther("100");
const index = RAY;
// Mint to user1
await mockLendingPool.mockMint(reservePool.address, user1.address, mintAmount, index);
// Simulate interest accumulation by updating liquidity index
await mockLendingPool.mockGetNormalizedIncome(ethers.parseUnits("1.1", 27));
// User1 transfers entire balance
let transferAmount = await rToken.balanceOf(user1);
await expect(rToken.connect(user1).transfer(user2.address, transferAmount))
.to.emit(rToken, "Transfer")
.withArgs(user1.address, user2.address, 90909090909090909091n);
// Validate incorrect balance leftover
expect(await rToken.balanceOf(user1)).to.equal(9090909090909090909n);
expect(await rToken.balanceOf(user2)).to.equal(90909090909090909091n);
});

Expected vs. Actual Behavior

Action Expected Transfer Amount Actual Transfer Amount (Bug)
User1 transfers 100 RTokens 100 ~90.9 (due to double scaling)
User1’s balance after transfer 0 ~9.09 (residual amount)

Recommendation

Since _update already handles scaling, remove the scaling operation from transfer and 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);
+ 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);
}

By passing the raw amount instead of a scaled amount, the bug is fixed while still ensuring correct scaling inside _update.

Updates

Lead Judging Commences

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