Core Contracts

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

RToken can be stuck in StabilityPool

Summary

RToken can be stuck in StabilityPool due to incorrect transfer() implementation in RToken contract.

Vulnerability Details

User deposits RToken tokens into StabilityPool to mint DeToken, when withdraws, the minted DeToken tokens are burned and RToken are transferred to the user.

StabilityPool::withdraw()

deToken.burn(msg.sender, deCRVUSDAmount);
@> rToken.safeTransfer(msg.sender, rcrvUSDAmount);

The problem is that RToken's transfer() divides the transferred amount by LendingPool's reserve liquidity index before the actual transfer.

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

This may lead to that some RToken tokens stuck in StabilityPool if reserve liquidity index is larger than 0.

It is worth mentioning that normal transfer may also leave some tokens in sender's account, for example, say reserve liquidity index is 1.4, when sender calls transfer() to transfer 140 tokens, the recipient will only receive 100 tokens while the 40 tokens are left in the sender's account, however, this can be mitigated by calling transfer() by specifying amount to 140 * 1.4.

Impact

RToken tokens are stuck in StabilityPool.

POC

Please run forge test --mt testAudit_RTokenTransfer.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console, stdError} from "forge-std/Test.sol";
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import "../contracts/core/pools/LendingPool/LendingPool.sol";
import "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import "../contracts/mocks/core/tokens/crvUSDToken.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/primitives/RAACHousePrices.sol";
import "../contracts/core/minters/RAACMinter/RAACMinter.sol";
contract Audit is Test {
using WadRayMath for uint256;
using SafeCast for uint256;
address owner = makeAddr("Owner");
LendingPool lendingPool;
StabilityPool stabilityPool;
RAACHousePrices raacHousePrices;
crvUSDToken crvUSD;
RToken rToken;
DebtToken debtToken;
DEToken deToken;
RAACToken raacToken;
RAACNFT raacNft;
RAACMinter raacMinter;
function setUp() public {
vm.warp(1 days);
raacHousePrices = new RAACHousePrices(owner);
crvUSD = new crvUSDToken(owner);
rToken = new RToken("RToken", "RToken", owner, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", owner);
raacNft = new RAACNFT(address(crvUSD), address(raacHousePrices), owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
raacToken = new RAACToken(owner, 100, 50);
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(raacNft),
address(raacHousePrices),
0.1e27
);
lendingPool.transferOwnership(owner);
// Deploy stabilityPool Proxy
bytes memory data = abi.encodeWithSelector(
StabilityPool.initialize.selector,
address(rToken),
address(deToken),
address(raacToken),
address(owner),
address(crvUSD),
address(lendingPool)
);
address stabilityPoolProxy = address(
new TransparentUpgradeableProxy(
address(new StabilityPool(owner)),
owner,
data
)
);
stabilityPool = StabilityPool(stabilityPoolProxy);
raacMinter = new RAACMinter(
address(raacToken),
address(stabilityPool),
address(lendingPool),
owner
);
vm.startPrank(owner);
raacHousePrices.setOracle(owner);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(raacMinter), true);
raacToken.manageWhitelist(address(stabilityPool), true);
deToken.setStabilityPool(address(stabilityPool));
stabilityPool.setRAACMinter(address(raacMinter));
lendingPool.setStabilityPool(address(stabilityPool));
vm.stopPrank();
vm.label(address(crvUSD), "crvUSD");
vm.label(address(rToken), "RToken");
vm.label(address(debtToken), "DebtToken");
vm.label(address(deToken), "DEToken");
vm.label(address(raacToken), "RAACToken");
vm.label(address(raacNft), "RAAC NFT");
vm.label(address(lendingPool), "LendingPool");
vm.label(address(stabilityPool), "StabilityPool");
vm.label(address(raacMinter), "RAACMinter");
}
function testAudit_RTokenTransfer() public {
// Deposit liquidity
uint256 depositAmount = 1000e18;
address alice = makeAddr("Alice");
crvUSD.mint(alice, depositAmount);
vm.startPrank(alice);
crvUSD.approve(address(lendingPool), depositAmount);
lendingPool.deposit(depositAmount);
vm.stopPrank();
// Deposit NFT
uint256 nftTokenId = 1;
uint256 nftPrice = 2000e18;
vm.prank(owner);
raacHousePrices.setHousePrice(nftTokenId, nftPrice);
address bob = makeAddr("Bob");
crvUSD.mint(bob, nftPrice);
vm.startPrank(bob);
crvUSD.approve(address(raacNft), nftPrice);
raacNft.mint(nftTokenId, nftPrice);
raacNft.approve(address(lendingPool), nftTokenId);
lendingPool.depositNFT(nftTokenId);
vm.stopPrank();
// Borrow liquidity
uint256 borrowAmount = 1000e18;
vm.prank(bob);
lendingPool.borrow(borrowAmount);
vm.warp(block.timestamp + 365 days);
lendingPool.updateState();
// Repay debt
uint256 bobDebt = lendingPool.getUserDebt(bob);
crvUSD.mint(bob, bobDebt - borrowAmount);
vm.startPrank(bob);
crvUSD.approve(address(lendingPool), bobDebt);
lendingPool.repay(bobDebt);
vm.stopPrank();
// Deposits into StabilityPool
uint256 aliceRTokenBalance = rToken.balanceOf(alice);
vm.startPrank(alice);
rToken.approve(address(stabilityPool), aliceRTokenBalance);
stabilityPool.deposit(aliceRTokenBalance);
vm.stopPrank();
// Withdraw from StabilityPool
uint256 aliceDETokenBalance = deToken.balanceOf(alice);
vm.prank(alice);
stabilityPool.withdraw(aliceDETokenBalance);
// Some rToken stuck
assertEq(rToken.balanceOf(address(stabilityPool)), 400e18);
}
}

Tools Used

Manual Review

Recommendations

Use ERC20's transfer() when interacts with StabilityPool.

function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
+ (bool success, bytes memory data) = _reservePool.call(abi.encodeWithSignature("stabilityPool()"));
+ require(success);
+ address stabilityPool = abi.decode(data, (address));
+ if (msg.sender == stabilityPool || recipient == stabilityPool) {
+ return super.transfer(recipient, amount);
+ }
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}
Updates

Lead Judging Commences

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

StabilityPool's userDeposits mapping doesn't update with DEToken transfers or interest accrual, and this combined with RToken transfers causes fund loss and permanent lockup

Support

FAQs

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

Give us feedback!