Core Contracts

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

Inconsistent Scaling in RToken Transfer Functions

Overview

RToken is an interest‐bearing token for the RAAC lending protocol. It is modeled similarly to Aave’s AToken, where user balances (and the total supply) are stored in “scaled” form and then converted to underlying units by multiplying with a “liquidity index” (or normalized income).

  • Balance Calculation:
    The contract’s overridden balanceOf and totalSupply functions compute the actual (underlying) amounts by taking the stored scaled balances and multiplying them by the normalized income, obtained via:

    ILendingPool(_reservePool).getNormalizedIncome()
  • Transfer Logic:
    The transfer function converts the underlying amount provided by the caller into a scaled amount by dividing by the normalized income from the reserve pool:

    uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());

    In contrast, the transferFrom function uses the stored _liquidityIndex instead:

    uint256 scaledAmount = amount.rayDiv(_liquidityIndex);

If the stored _liquidityIndex becomes out‑of‑sync with the current normalized income reported by the reserve pool, transfers executed via transferFrom will convert underlying amounts incorrectly relative to transfer (and relative to balance calculations). Such inconsistencies could lead to inadvertent token losses, unexpected rounding differences, or even potential exploitation if an attacker can force a discrepancy between the two values.


Root Cause & Attack Path

  • Root Cause:
    The contract relies on two distinct sources for its conversion factor:

    • transfer (and balanceOf, totalSupply, _update) uses the current normalized income from the LendingPool.

    • transferFrom uses the internally stored _liquidityIndex, which is updated only when updateLiquidityIndex is called by the reserve pool.

    In a properly functioning system these values should match; however, if for any reason they diverge (for instance, due to delayed updates or malicious manipulation of the reserve pool), the conversion in transferFrom will be incorrect.

  • Potential Impact:

    • User Losses: A user invoking transferFrom might end up transferring too few or too many tokens compared to their intended underlying amount.

    • Exploitation: An attacker might leverage any temporary discrepancy between _liquidityIndex and the normalized income to cause transfers that undervalue or overvalue token amounts, thereby disrupting the economic equilibrium.

    • Protocol Inconsistency: Inconsistent token conversion undermines the reliability of the interest accrual mechanism, affecting all downstream processes that rely on accurate token balances.

  • Attack Scenario (Hypothetical):
    Suppose the reserve pool’s normalized income increases (reflecting accrued interest) but the stored _liquidityIndex is not updated immediately. Then:

    • A caller using transfer would convert an underlying amount using the higher (current) normalized income.

    • A caller using transferFrom would use the lower stored _liquidityIndex, resulting in a larger scaled amount being transferred.
      This discrepancy could result in the recipient receiving an unexpectedly high underlying amount, potentially allowing an attacker to “drain” tokens from an unwary user or to profit from the rounding differences.


Foundry PoC Demonstration

Simplified Foundry test illustrates the potential inconsistency when the stored _liquidityIndex differs from the normalized income returned by the reserve pool. In our PoC, we simulate the scenario by using a mock LendingPool and exposing a setter in our test version of RToken (RTokenMock) to modify the internal liquidity index.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "../../libraries/math/WadRayMath.sol";
import "../../interfaces/core/pools/LendingPool/ILendingPool.sol";
// Mock LendingPool that returns a fixed normalized income.
contract MockLendingPool is ILendingPool {
uint256 private _normalizedIncome;
constructor(uint256 normalizedIncome_) {
_normalizedIncome = normalizedIncome_;
}
function getNormalizedIncome() external view override returns (uint256) {
return _normalizedIncome;
}
}
// Simplified RToken contract for testing.
contract RTokenMock is ERC20, ERC20Permit, Ownable {
using WadRayMath for uint256;
uint256 private _liquidityIndex; // stored internal index
address private _reservePool;
constructor(
string memory name,
string memory symbol,
address owner_,
address reservePool_
) ERC20(name, symbol) ERC20Permit(name) Ownable(owner_) {
_reservePool = reservePool_;
_liquidityIndex = 1e27; // initial RAY value.
}
// Expose a setter for testing purposes.
function setLiquidityIndex(uint256 newIndex) external onlyOwner {
_liquidityIndex = newIndex;
}
// Overridden balanceOf uses normalized income from the reserve pool.
function balanceOf(address account) public view override returns (uint256) {
uint256 scaledBalance = super.balanceOf(account);
uint256 normalizedIncome = ILendingPool(_reservePool).getNormalizedIncome();
return scaledBalance * normalizedIncome / 1e27;
}
// transfer uses normalized income from the reserve pool.
function transfer(address recipient, uint256 amount) public override returns (bool) {
uint256 normalizedIncome = ILendingPool(_reservePool).getNormalizedIncome();
uint256 scaledAmount = amount * 1e27 / normalizedIncome;
return super.transfer(recipient, scaledAmount);
}
// transferFrom uses the stored _liquidityIndex.
function transferFrom(address sender, address recipient, uint256 amount) public override returns (bool) {
uint256 scaledAmount = amount * 1e27 / _liquidityIndex;
return super.transferFrom(sender, recipient, scaledAmount);
}
// For testing: mint tokens directly (in scaled form).
function mint(address to, uint256 scaledAmount) external onlyOwner {
_mint(to, scaledAmount);
}
}
contract RTokenScalingTest is Test {
RTokenMock public rToken;
MockLendingPool public pool;
address owner = address(1);
address user = address(2);
address recipient = address(3);
function setUp() public {
// Simulate a reserve pool with normalized income of 2e27 (double RAY).
pool = new MockLendingPool(2e27);
rToken = new RTokenMock("RToken", "RTKN", owner, address(pool));
// Mint 100 scaled tokens to the user.
vm.prank(owner);
rToken.mint(user, 100e18);
}
function testTransferConsistency() public {
// Underlying balance computed via balanceOf:
// 100e18 scaled tokens * 2e27 / 1e27 = 200e18.
uint256 userUnderlying = rToken.balanceOf(user);
assertEq(userUnderlying, 200e18, "User underlying balance should be 200 tokens");
// User transfers 50 underlying tokens via transfer.
vm.prank(user);
rToken.transfer(recipient, 50e18);
// Expected: recipient gets 50 underlying tokens.
uint256 recipientBalance = rToken.balanceOf(recipient);
assertEq(recipientBalance, 50e18, "Recipient should receive 50 underlying tokens");
}
function testTransferFromInconsistency() public {
// Simulate a scenario where the stored _liquidityIndex is outdated.
// Set _liquidityIndex to 1.5e27 while normalized income remains 2e27.
vm.prank(owner);
rToken.setLiquidityIndex(1.5e27);
// User approves transferFrom.
vm.prank(user);
rToken.approve(address(this), 25e18);
// Call transferFrom to transfer 25 underlying tokens.
uint256 recipientBefore = rToken.balanceOf(recipient);
bool success = rToken.transferFrom(user, recipient, 25e18);
require(success, "transferFrom failed");
uint256 recipientAfter = rToken.balanceOf(recipient);
uint256 diff = recipientAfter - recipientBefore;
// Calculation using stored _liquidityIndex:
// Scaled amount = 25e18 * 1e27 / 1.5e27 ≈ 16.67e18.
// Then underlying amount received = 16.67e18 * normalized income (2e27) / 1e27 ≈ 33.33e18.
// In contrast, if using normalized income directly (as in transfer), 25e18 underlying would yield 50e18.
// Thus, the discrepancy is evident.
assertApproxEqAbs(diff, 33.33e18, 1e16, "transferFrom underlying amount inconsistent with expected value");
}
}
  • In the PoC, the reserve pool’s normalized income is set to 2e27, while initially the stored liquidity index is 1e27.

  • The transfer function correctly converts an underlying amount (e.g., 50e18) using the normalized income, resulting in the recipient receiving the intended underlying amount.

  • However, in transferFrom, we manually set the stored _liquidityIndex to 1.5e27, causing the conversion to differ. As a result, transferring 25 underlying units ends up converting to a scaled amount based on 1.5e27 rather than 2e27, yielding a different underlying value (~33.33 tokens instead of 50 tokens).

  • This inconsistency proves that if the stored liquidity index and the reserve pool’s normalized income diverge, transferFrom will yield incorrect underlying transfers.


Mitigation

Recommended Fix:
To ensure consistent behavior across all transfer functions, both transfer and transferFrom must use the same conversion factor. The fix is to update transferFrom so that it uses the current normalized income from the reserve pool rather than the stored _liquidityIndex. For example, modify:

uint256 scaledAmount = amount.rayDiv(_liquidityIndex);

to

uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());

This change guarantees that all conversions from underlying amounts to scaled amounts are consistent, preventing potential token losses or exploitation due to conversion discrepancies.

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RToken::transfer uses getNormalizedIncome() while transferFrom uses _liquidityIndex, creating inconsistent transfer amounts depending on function used

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RToken::transfer uses getNormalizedIncome() while transferFrom uses _liquidityIndex, creating inconsistent transfer amounts depending on function used

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!