Core Contracts

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

RToken's transfer function lead to loss of funds due to incorrect math

Summary

The RToken's transfer and transferFromfunctions incorrectly apply scaling in the transfer amount, which causes the _update function, which is triggered at the end of the transfer flow, to apply a second scaling, thus causing the amount to be transferred smaller than the original amount.

StabilityPool uses RToken's transfer function when users want to withdraw their RTokens from the pool. Users end up receiving fewer tokens than intended, resulting in permanent loss of their RTokens.

Vulnerability Details

Incorrect math when transferring tokens:

function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
// @audit-issue should pass the raw amount.
@> 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) {
// @audit-issue should pass the raw amount.
@> uint256 scaledAmount = amount.rayDiv(_liquidityIndex);
return super.transferFrom(sender, recipient, scaledAmount);
}

Notice later on the _update function will normalize the amount before the transfer is finally done.

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);
}

The correct behavior should be to pass the raw amount to super.transfer, so the _update function would update the balances with the correct amount, accounting for the interest accrued.

As the scaling is applied twice, the amount of tokens to be sent will be less than it should be.

Example:

Incorrect amount being transferred:

  • Bob has 100 RTokens

  • RTokens accrue interest over time

  • Bob calls transfer to send 100 RTokens to Alice

  • Alice received the amount that does not reflect the amount sent by Bob, much less in fact.

Loss of funds when trying to withdraw RToken from StabilityPool

  • Bob has 100 RTokens

  • RTokens accrue interest over time

  • Bob deposits his 100 RTokens into the StabilityPooland receives the same amount in DeToken.

  • Bob calls withdraw in StabilityPool to withdraw all his funds.

  • Withdraw function deletes the user deposit balance delete userDeposits[msg.sender] and calls RToken.safeTransfer.

  • Transfer function sent less tokens than it should have due to incorrect math.

  • Bob lost his RTokens. Funds are stuck in StabilityPool.

  • Bob cannot withdraw the stuck funds as he no longer has a deposit balance in StabilityPool.

PoC

The scenarios above are represented in this PoC.

  1. Install foundry through:

    • npm i --save-dev @nomicfoundation/hardhat-foundry

    • Add require("@nomicfoundation/hardhat-foundry");on hardhat config file

    • Run npx hardhat init-foundry and forge install foundry-rs/forge-std --no-commit

  2. Create a file called RToken.t.solin the test folder

  3. Paste the code below

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import "../contracts/core/governance/boost/BoostController.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import "../contracts/interfaces/core/tokens/IveRAACToken.sol";
import "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import "../contracts/core/pools/LendingPool/LendingPool.sol";
import "../contracts/core/tokens/RToken.sol";
import "../contracts/core/tokens/DebtToken.sol";
import "../contracts/core/tokens/DEToken.sol";
import "../contracts/core/tokens/RAACToken.sol";
import "../contracts/core/tokens/RAACNFT.sol";
import "../contracts/core/minters/RAACMinter/RAACMinter.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import "../contracts/core/primitives/RAACHousePrices.sol";
import "../contracts/mocks/core/tokens/crvUSDToken.sol";
contract RTokenTest is Test {
using WadRayMath for uint256;
// contracts
StabilityPool stabilityPool;
LendingPool lendingPool;
RToken rToken;
DEToken deToken;
DebtToken debtToken;
RAACMinter raacMinter;
crvUSDToken crvUSD;
RAACToken raacToken;
RAACHousePrices public raacHousePrices;
RAACNFT public raacNFT;
// users
address owner = address(1);
address user1 = address(2);
address user2 = address(3);
address user3 = address(4);
address[] users = new address[](3);
function setUp() public {
// setup users
users[0] = user1;
users[1] = user2;
users[2] = user3;
vm.label(user1, "USER1");
vm.label(user2, "USER2");
vm.label(user3, "USER3");
// initiate timestamp and block
vm.warp(1738798039); // 2025-02-05
vm.roll(100); // block
vm.startPrank(owner);
_deployAndSetupContracts();
vm.stopPrank();
_mintCrvUsdTokenToUsers(1000e18);
}
function test_RToken_transfer_send_incorrectAmount() public {
// pre condition
// 1.user has RTokens
uint256 depositAmount = 100e18;
vm.prank(user1);
lendingPool.deposit(depositAmount);
uint256 liquidityIndexLendingPoolBefore = _liquidityIndexFromLendingPool();
uint256 liquidityIndexRTokenBefore = rToken.getLiquidityIndex();
// 2.RToken accrue interest
_accrueYieldForRToken();
// liquidityIndex from pool increases after accruing yield
assertGt(_liquidityIndexFromLendingPool(), liquidityIndexLendingPoolBefore);
// liquidityIndex from rToken is the same
assertEq(rToken.getLiquidityIndex(), liquidityIndexRTokenBefore);
// action
// user make transfer
// expected: receiver receives the amount sent
// result: receiver receives much less.
address newUser = address(0x3342432);
uint256 user1BalanceBefore = rToken.balanceOf(user1);
console.log("user balance before: %e", user1BalanceBefore);
vm.startPrank(user1);
rToken.transfer(newUser, rToken.balanceOf(user1));
vm.stopPrank();
console.log("new user received: %e", rToken.balanceOf(newUser));
console.log("amount of rToken user1 has: %e", rToken.balanceOf(user1));
// post condition
// newUser didn't receive the full amount transferred
assertLt(rToken.balanceOf(newUser), user1BalanceBefore);
// user1 still has tokens, even though he transferred full balance
// Crit: this will cause the StabilityPool to not return all the user's tokens.
assertGt(rToken.balanceOf(user1), 0);
}
function test_RTokenTransfer_leadToLossOfFunds() public {
// pre condition
// 1.user has RTokens
uint256 depositAmount = 100e18;
vm.prank(user1);
lendingPool.deposit(depositAmount);
uint256 liquidityIndexLendingPoolBefore = _liquidityIndexFromLendingPool();
uint256 liquidityIndexRTokenBefore = rToken.getLiquidityIndex();
// 2.RToken accrue yield
_accrueYieldForRToken();
// liquidityIndex from pool increases after accruing yield
assertGt(_liquidityIndexFromLendingPool(), liquidityIndexLendingPoolBefore);
// liquidityIndex from rToken is the same
assertEq(rToken.getLiquidityIndex(), liquidityIndexRTokenBefore);
uint256 user1BalanceBefore = rToken.balanceOf(user1);
console.log("user balance before: %e", user1BalanceBefore);
// action
// user deposit and withdraw from StabilityPool
// expected: User receives the same amount of RToken he deposited.
// result: user loses part of his tokens.
vm.startPrank(user1);
rToken.approve(address(stabilityPool), type(uint256).max);
// uses scaled balance to deposit all user funds
stabilityPool.deposit(rToken.balanceOf(user1));
vm.stopPrank();
// user has deposited all his RTokens
assertEq(rToken.balanceOf(user1), 0);
console.log("deToken minted after deposit: %e", deToken.balanceOf(user1));
// try to withdraw all RTokens
vm.startPrank(user1);
stabilityPool.withdraw(deToken.balanceOf(user1));
vm.stopPrank();
// user should have 0 deToken
assertEq(deToken.balanceOf(user1), 0, "user should not have deToken after withdraw the entire amount");
// deToken supply should be zero
assertEq(deToken.totalSupply(), 0, "deToken supply should be zero");
console.log("amount of rToken in stabilityPool: %e", rToken.balanceOf(address(stabilityPool)));
console.log("User lost %e in RTokens after withdraw:", rToken.balanceOf(address(stabilityPool)));
// user lost some of his tokens
assertLt(rToken.balanceOf(user1), user1BalanceBefore);
}
// HELPER FUNCTIONS
function _deployAndSetupContracts() internal {
// Deploy base tokens
crvUSD = new crvUSDToken(owner);
raacToken = new RAACToken(owner, 100, 50);
// Deploy real oracle
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner); // Set owner as oracle
// Deploy real NFT contract
raacNFT = new RAACNFT(
address(crvUSD),
address(raacHousePrices),
owner
);
// Deploy core contracts with proper constructor args
rToken = new RToken(
"RToken",
"RTK",
owner,
address(crvUSD)
);
deToken = new DEToken(
"DEToken",
"DET",
owner,
address(rToken)
);
debtToken = new DebtToken(
"DebtToken",
"DEBT",
owner
);
// Deploy pools with required constructor parameters
lendingPool = new LendingPool(
address(crvUSD), // reserveAssetAddress
address(rToken), // rTokenAddress
address(debtToken), // debtTokenAddress
address(raacNFT), // raacNFTAddress
address(raacHousePrices), // priceOracleAddress
0.8e27 // initialPrimeRate (RAY)
);
// Deploy RAACMinter with valid constructor args
raacMinter = new RAACMinter(
address(raacToken),
address(0x1234324423), // stability pool
address(lendingPool),
owner
);
stabilityPool = new StabilityPool(owner);
stabilityPool.initialize(
address(rToken), // _rToken
address(deToken), // _deToken
address(raacToken), // _raacToken
address(raacMinter), // _raacMinter
address(crvUSD), // _crvUSDToken
address(lendingPool) // _lendingPool
);
raacMinter.setStabilityPool(address(stabilityPool));
lendingPool.setStabilityPool(address(stabilityPool));
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
deToken.transferOwnership(address(stabilityPool));
// setup raacToken's minter and whitelist
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
}
function _liquidityIndexFromLendingPool() internal view returns(uint128) {
(, , , , , uint128 liquidityIndex, ,) = lendingPool.reserve();
return liquidityIndex;
}
function _depositCrvUsdIntoLendingPoolForAllUsers(uint256 initialDeposit) internal {
// iterate users array and deposit into lending pool
for (uint i = 0; i < users.length; i++) {
vm.prank(users[i]);
lendingPool.deposit(initialDeposit);
}
}
function _mintCrvUsdTokenToUsers(uint256 initialBalance) internal {
for (uint i = 0; i < users.length; i++) {
_mintCrvUsdTokenToUser(initialBalance, users[i]);
}
}
function _mintCrvUsdTokenToUser(uint256 initialBalance, address user) internal {
vm.prank(owner);
crvUSD.mint(user, initialBalance);
vm.startPrank(user);
crvUSD.approve(address(raacNFT), initialBalance);
crvUSD.approve(address(lendingPool), initialBalance);
rToken.approve(address(stabilityPool), initialBalance);
vm.stopPrank();
}
function _advanceInTime(uint256 time) internal {
vm.warp(block.timestamp + time);
vm.roll(block.number + 10000);
}
function _advanceInTimeAndAccrueInterestInLendingPool(uint256 time) internal {
uint256 usageIndex = lendingPool.getNormalizedDebt();
_advanceInTime(time);
lendingPool.updateState();
}
function _setupHousePrices(uint256 housePrice) internal {
vm.startPrank(owner);
raacHousePrices.setHousePrice(1, housePrice);
raacHousePrices.setHousePrice(2, housePrice);
raacHousePrices.setHousePrice(3, housePrice);
vm.stopPrank();
}
function _setupHousePrice(uint256 housePrice, uint256 newValue) internal {
vm.startPrank(owner);
raacHousePrices.setHousePrice(newValue, housePrice);
vm.stopPrank();
}
function _mintNFTwithTokenId(uint256 tokenId, uint256 housePrice) internal {
raacNFT.mint(tokenId, housePrice);
raacNFT.approve(address(lendingPool), tokenId);
}
function _mintAndDepositNftInLendingPool(uint256 tokenId, uint256 housePrice) internal {
_mintNFTwithTokenId(tokenId, housePrice);
lendingPool.depositNFT(tokenId);
}
function _borrowCrvUsdTokenFromLendingPool(uint256 amount) internal {
lendingPool.borrow(amount);
}
function _depositNftBorrowFundsAndMakeUserLiquidatable(address user, uint256 tokenId, uint256 nftPrice) internal {
_setupHousePrice(nftPrice, tokenId);
vm.startPrank(user);
_mintAndDepositNftInLendingPool(tokenId, nftPrice);
_borrowCrvUsdTokenFromLendingPool(nftPrice/2);
vm.stopPrank();
// accrue interest
_advanceInTimeAndAccrueInterestInLendingPool(365 days);
// house price drops, // user is now liquidatable.
_setupHousePrice(nftPrice/2, tokenId);
}
function _accrueYieldForRToken() internal {
// 1. user deposits NFT
// 2. user borrows CRV-USD
uint256 housePrice = 300e18;
_setupHousePrice(housePrice, 1);
vm.startPrank(user2);
_mintAndDepositNftInLendingPool(1, housePrice);
_borrowCrvUsdTokenFromLendingPool(50e18);
vm.stopPrank();
// accrue interest
_advanceInTimeAndAccrueInterestInLendingPool(365 days);
// user repay debt, increasing liquidityIndex(Rtoken accrued yield)
vm.startPrank(user2);
lendingPool.updateState();
lendingPool.repay(debtToken.balanceOf(user2));
vm.stopPrank();
// user has no debt after repay
assertEq(lendingPool.getUserDebt(user2), 0);
// no debt in debtToken total supply
assertEq(debtToken.totalSupply(), 0);
}
}

Run: forge test --match-contract RTokenTest -vv

Result:

Ran 2 tests for test/Rtoken.t.sol:RTokenTest
[PASS] test_RTokenTransfer_leadToLossOfFunds() (gas: 1038079)
Logs:
user balance before: 1.2875e20
deToken minted after deposit: 1.2875e20
amount of rToken in stabilityPool: 2.875e19
User lost 2.875e19 in RTokens after withdraw:
[PASS] test_RToken_transfer_send_incorrectAmount() (gas: 784016)
Logs:
user balance before: 1.2875e20
new user received: 1e20
amount of rToken user1 has: 2.875e19
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 8.11ms (3.95ms CPU time)

Impact

  • Loss of funds when withdrawing RTokens from StabilityPool

  • Incorrect amount of funds sent when using the transferfunction

  • Protocols integrating with RToken may expose themselves or their users to potential financial losses.

Tools Used

Manual Review & Foundry

Recommendations

Pass the raw amount to super.transfer:

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);
}

Run the PoC again, and notice that for both tests there is no loss of funds/incorrect amounts:

Ran 2 tests for test/Rtoken.t.sol:RTokenTest
[FAIL: assertion failed: 128750000000000000000 >= 128750000000000000000] test_RTokenTransfer_leadToLossOfFunds() (gas: 1301715)
Logs:
user balance before: 1.2875e20
deToken minted after deposit: 1.2875e20
amount of rToken in stabilityPool: 0e0
User lost 0e0 in RTokens after withdraw:
[FAIL: assertion failed: 128750000000000000000 >= 128750000000000000000] test_RToken_transfer_send_incorrectAmount() (gas: 949412)
Logs:
user balance before: 1.2875e20
new user received: 1.2875e20
amount of rToken user1 has: 0e0
Suite result: FAILED. 0 passed; 2 failed; 0 skipped; finished in 8.73ms (4.64ms CPU time)
Updates

Lead Judging Commences

inallhonesty Lead Judge 5 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 5 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.