[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);
}
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);
}
Issue in _update
Function
function _update(
address from,
address to,
uint256 amount
) internal override {
uint256 scaledAmount = amount.rayDiv(
ILendingPool(_reservePool).getNormalizedIncome()
);
super._update(from, to, scaledAmount);
}
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;
await mockLendingPool.mockMint(reservePool.address, user1.address, mintAmount, index);
await mockLendingPool.mockGetNormalizedIncome(ethers.parseUnits("1.1", 27));
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);
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
.