Core Contracts

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

Users lose tokens when transferring due to double scaling

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 {
// Scale amount by normalized income for all operations (mint, burn, transfer)
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); // 55 / 1.1e27 = 50 scaled RTokens
super.transfer(Bob, 50);

_update Function (called by super.transfer):

uint256 scaledAmount = 50.rayDiv(1.1e27); // 50 / 1.1e27 ≈ 45.45 scaled RTokens
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.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/RToken.sol";
// Harness to expose internal _mint for testing
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);
}
}
// Mock ReservePool to return liquidity index
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); // Mock asset (not used here)
function setUp() public {
mockPool = new MockReservePool();
mockPool.setNormalizedIncome(1.1e27); // Set liquidity index to 1.1 RAY (10% interest)
rToken = new RTokenHarness("RToken", "RTK", address(this), asset);
rToken.setReservePool(address(mockPool));
// Mint 100 scaled RTokens to Alice (100e27 scaled units)
rToken.mintDirect(alice, 100e27);
}
function testTransferDoubleScalingIssue() public {
// Check initial balances (in underlying units)
uint256 aliceInitial = rToken.balanceOf(alice);
assertEq(aliceInitial, 110e27, "Alice's initial balance should be 110e27");
// Alice attempts to transfer 55 underlying tokens (55e27) to Bob
vm.prank(alice);
rToken.transfer(bob, 55e27); // 55e27 underlying units
// Check balances after transfer
uint256 aliceFinal = rToken.balanceOf(alice);
uint256 bobFinal = rToken.balanceOf(bob);
// Expected (if transfer worked correctly):
// Alice: 110e27 - 55e27 = 55e27
// Bob: 0 + 55e27 = 55e27
// Actual (due to double scaling):
// Alice: 60e27, Bob: 50e27
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) {
// Remove manual scaling; let _update handle it once
return super.transfer(recipient, amount);
}
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

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!