Summary
The transfer and transferFrom functions manually scale the input amount (using rayDiv), which is then scaled again in _update. This results in double division by the liquidity index, causing transferred amounts to be smaller than intended.
Example:
Transferring 100 tokens at index 1.1e27 becomes 100 / (1.1e27)² in scaled terms, breaking the balance conservation.
Vulnerability Details
* @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 {
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
super._update(from, to, scaledAmount);
}
function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}
* @dev Overrides the ERC20 transferFrom function to use scaled amounts
* @param sender The sender address
* @param recipient The recipient address
* @param amount The amount to transfer (in underlying asset units)
*/
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);
}
Example:
Liquidity Index: 1.1e27 (10% interest accrued).
Alice has 110 underlying tokens (e.g., crvUSD) deposited, represented as 100 scaled RTokens (110 / 1.1e27).
Bob has 0 RTokens.
Total Scaled Supply: 100 RTokens.
Total Underlying Assets: 110 crvUSD (matches 100 × 1.1e27)
Alice calls transfer(Bob, 55) to send Bob 55 crvUSD worth of RTokens
The code executes as follows:
uint256 scaledAmount = 55.rayDiv(1.1e27);
super.transfer(Bob, 50);
_update Function (called by super.transfer):
uint256 scaledAmount = 50.rayDiv(1.1e27);
super._update(Alice, Bob, 45.45);
Alice’s Balance: 100 - 45.45 = 54.55 scaled RTokens.
Bob’s Balance: 0 + 45.45 = 45.45 scaled RTokens.
Total Scaled Supply: Still 100 (no mint/burn)
Underlying Value Check:
Total underlying assets should remain 110 crvUSD.
Actual total: 100 × 1.1e27 = 110 crvUSD.
The invariant holds, but the transfer is incorrect.
Intended Transfer: 55 crvUSD (50 scaled RTokens).
Actual Transfer: 45.45 scaled RTokens (≈ 50 crvUSD).
Discrepancy: Alice tried to send 55 crvUSD, but only 50 crvUSD moved.
The total underlying remains 110, but Alice’s intended transfer of 55 crvUSD was silently reduced to 50 crvUSD. The invariant technically holds, but user balances are corrupted.
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/RToken.sol";
contract RTokenHarness is RToken {
constructor(
string memory name,
string memory symbol,
address initialOwner,
address assetAddress
) RToken(name, symbol, initialOwner, assetAddress) {}
function mintDirect(address to, uint256 scaledAmount) public {
_mint(to, scaledAmount);
}
}
contract MockReservePool {
uint256 public normalizedIncome;
function getNormalizedIncome() external view returns (uint256) {
return normalizedIncome;
}
function setNormalizedIncome(uint256 index) external {
normalizedIncome = index;
}
}
contract RTokenTest is Test {
RTokenHarness public rToken;
MockReservePool public mockPool;
address public alice = address(0x1);
address public bob = address(0x2);
address public asset = address(0x3);
function setUp() public {
mockPool = new MockReservePool();
mockPool.setNormalizedIncome(1.1e27);
rToken = new RTokenHarness("RToken", "RTK", address(this), asset);
rToken.setReservePool(address(mockPool));
rToken.mintDirect(alice, 100e27);
}
function testTransferDoubleScalingIssue() public {
uint256 aliceInitial = rToken.balanceOf(alice);
assertEq(aliceInitial, 110e27, "Alice's initial balance should be 110e27");
vm.prank(alice);
rToken.transfer(bob, 55e27);
uint256 aliceFinal = rToken.balanceOf(alice);
uint256 bobFinal = rToken.balanceOf(bob);
assertEq(aliceFinal, 60e27, "Alice's balance after transfer is incorrect");
assertEq(bobFinal, 50e27, "Bob's balance after transfer is incorrect");
}
}
Impact
The double scaling in transfer/transferFrom silently corrupts user balances while maintaining the total supply invariant. Users will lose silently some of their tokens when transferring. This is a subtle bug.
Tools Used
Foundry
Recommendations
Remove double scaling
function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
return super.transfer(recipient, amount);
}