Core Contracts

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

Lenders lose deposited crvUSD and accrued interest due to incorrect calculation in RToken

Lenders lose deposited crvUSD and accrued interest due to incorrect calculation in RToken

Summary

Lenders who deposit into the Lending and Stability pools will not receive interest from borrowers and even lose a portion of their own deposited crvUSD when withdrawing due to an incorrect calculation in the RToken contract. The RToken contract overrides the transfer function to work with a scaled amount. However, the issue arises from how the scaledAmount is calculated. Instead of using rayDiv, the function should utilize rayMul to ensure accurate computations.

Vulnerability Details

The vulnerability lies in the RToken contract's transfer function implementation, which incorrectly scales token amounts during transfers. The function uses rayDiv operation instead of the correct rayMul operation when calculating scaled amounts:

Imagine the following scenario:

  1. Bob and Alice deposit crvUSD into the LendingPool and get minted RTokens

  2. Bob and Alice depoist these RTokens into the StabilityPool and get minted DETokens.

  3. Eve and Steve deposit their RAACNFT as collateral and borrow some of Bob and Alice's tokens

  4. Bob and Alice's tokens accrue interest

  5. After some time Eve and Steve decide to repay the full borrowed amount + the interest accrued

  6. Bob and Alice withdraw their RTokens by calling StabilityPool::withdraw which burns their DETokens and gives them the RAACToken rewards.

  7. The issue comes when Bob and Alice call the StabilityPool::withdraw function. It calls the rToken.safeTransfer(msg.sender, rcrvUSDAmount) function. The safeTransfer calls the transfer function in the RToken contract, but this function is overriden to work with the scaled amount which is incorrectly calculated using rayDiv instead of rayMul:

  8. Bob and Alice receive a slightly less amount of RTokens than they are supposed to.

  9. Finally Bob and Alice decide to withdraw their crvUSD from the LendingPool. However they not only lose on the crvUSD interest paid by the borrowers (Eve and Steve), they also lose a part of their initial deposit.

This scenario is shown in the PoC provided below.

Vulnerable code:

/**
* @dev Overrides the ERC20 transfer function to use scaled amounts
* @param recipient The recipient address
* @param amount The amount to transfer (in underlying asset units)
*/
function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}

Impact

  1. Direct financial loss for lenders.

  2. Permanent loss of accrued interest in the LendingPool

  3. Partial loss of initial deposits.

Tools Used

Manual Review, Foundry Test

PoC

To set up the test environment:

  1. Run foundryup

  2. Run foundry init

  3. Run forge install OpenZeppelin/openzeppelin-contracts --no-git and forge install https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable --no-git

  4. Paste this in foundry.toml - remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts", "@openzeppelin/contracts-upgradeable=lib/openzeppelin-contracts-upgradeable/contracts"]

  5. Then create a folder in src called RAACProtocol.

  6. In the RAACProtocol create two folders called: interfaces, libraries and tokens

  7. Into the interfaces paste the following contracts:

Interfaces
ICurveCrvUSDVault.sol,
IDebtToken.sol,
IDEToken.sol,
ILendingPool.sol,
IRAACHousePrices.sol,
IRAACMinter.sol,
IRAACNFT.sol,
IRAACToken.sol,
IRToken.sol,
IStabilityPool.sol

  1. Into the libraries paste the following contracts:

Libraries
PercentageMath.sol,
ReserveLibrary.sol,
WadRayMath.sol

  1. Into the tokens paste the following contracts:

Tokens
DebtToken.sol,
DEToken.sol,
RAACNFT.sol,
RAACToken.sol,
RToken.sol

  1. Create files in the test folder for the mocks and paste them.

    • OracleMock.sol for the oracle mock

    • CrvUSDMock.sol for the crvUSD mock

  2. Create file in the test folder called RAACProtocolTest.t.sol.

  3. Paste the test setup.

  4. Paste the test and the helper functions inside the StabilityAndLendingPoolTest contract.

Mocks

  1. Oracle Mock

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract RAACHousePrices {
function tokenToHousePrice(uint256 tokenId) public pure returns (uint256 price) {
price = 10_000 ether;
}
function getLatestPrice(uint256 tokenId) public returns (uint256 price, uint256 lastUpdateTimestamp) {
price = 10_000 ether;
lastUpdateTimestamp = block.timestamp;
}
}
  1. CrvUSD Mock

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title crvUSD Stablecoin
* @author Curve.Fi (Vyper implementation) - Converted to Solidity
* @notice Implementation of the crvUSD stablecoin contract
*/
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address recipient, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
interface IERC1271 {
function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4);
}
contract CrvUSDMock is IERC20 {
uint8 public constant decimals = 18;
string public constant version = "v1.0.0";
bytes4 private constant ERC1271_MAGIC_VAL = 0x1626ba7e;
bytes32 private constant EIP712_TYPEHASH =
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)");
bytes32 private constant EIP2612_TYPEHASH =
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 private immutable VERSION_HASH;
bytes32 private immutable NAME_HASH;
uint256 private immutable CACHED_CHAIN_ID;
bytes32 private immutable CACHED_DOMAIN_SEPARATOR;
bytes32 public immutable salt;
string public name;
string public symbol;
mapping(address => mapping(address => uint256)) public allowance;
mapping(address => uint256) public balanceOf;
uint256 public totalSupply;
mapping(address => uint256) public nonces;
event SetMinter(address indexed minter);
constructor(string memory _name, string memory _symbol) {
name = _name;
symbol = _symbol;
NAME_HASH = keccak256(bytes(_name));
VERSION_HASH = keccak256(bytes(version));
CACHED_CHAIN_ID = block.chainid;
salt = blockhash(block.number - 1);
CACHED_DOMAIN_SEPARATOR =
keccak256(abi.encode(EIP712_TYPEHASH, NAME_HASH, VERSION_HASH, block.chainid, address(this), salt));
emit SetMinter(msg.sender);
}
function _approve(address owner, address spender, uint256 value) internal {
allowance[owner][spender] = value;
emit Approval(owner, spender, value);
}
function _burn(address from, uint256 value) internal {
balanceOf[from] -= value;
totalSupply -= value;
emit Transfer(from, address(0), value);
}
function _transfer(address from, address to, uint256 value) internal {
require(to != address(this) && to != address(0), "Invalid recipient");
balanceOf[from] -= value;
balanceOf[to] += value;
emit Transfer(from, to, value);
}
function _domainSeparator() internal view returns (bytes32) {
if (block.chainid != CACHED_CHAIN_ID) {
return keccak256(abi.encode(EIP712_TYPEHASH, NAME_HASH, VERSION_HASH, block.chainid, address(this), salt));
}
return CACHED_DOMAIN_SEPARATOR;
}
function transferFrom(address from, address to, uint256 value) external override returns (bool) {
uint256 currentAllowance = allowance[from][msg.sender];
if (currentAllowance != type(uint256).max) {
_approve(from, msg.sender, currentAllowance - value);
}
_transfer(from, to, value);
return true;
}
function transfer(address to, uint256 value) external override returns (bool) {
_transfer(msg.sender, to, value);
return true;
}
function approve(address spender, uint256 value) external override returns (bool) {
_approve(msg.sender, spender, value);
return true;
}
function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
external
returns (bool)
{
require(owner != address(0) && block.timestamp <= deadline, "Invalid permit");
uint256 nonce = nonces[owner];
bytes32 digest = keccak256(
abi.encodePacked(
"\x19\x01",
_domainSeparator(),
keccak256(abi.encode(EIP2612_TYPEHASH, owner, spender, value, nonce, deadline))
)
);
if (Address.isContract(owner)) {
bytes memory signature = abi.encodePacked(r, s, v);
require(IERC1271(owner).isValidSignature(digest, signature) == ERC1271_MAGIC_VAL, "Invalid signature");
} else {
address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress == owner, "Invalid signature");
}
nonces[owner] = nonce + 1;
_approve(owner, spender, value);
return true;
}
function increaseAllowance(address spender, uint256 addedValue) external returns (bool) {
uint256 currentAllowance = allowance[msg.sender][spender];
uint256 newAllowance = currentAllowance + addedValue;
// Check for overflow
if (newAllowance < currentAllowance) {
newAllowance = type(uint256).max;
}
if (newAllowance != currentAllowance) {
_approve(msg.sender, spender, newAllowance);
}
return true;
}
function decreaseAllowance(address spender, uint256 subtractedValue) external returns (bool) {
uint256 currentAllowance = allowance[msg.sender][spender];
uint256 newAllowance;
if (subtractedValue >= currentAllowance) {
newAllowance = 0;
} else {
newAllowance = currentAllowance - subtractedValue;
}
if (newAllowance != currentAllowance) {
_approve(msg.sender, spender, newAllowance);
}
return true;
}
function burnFrom(address from, uint256 value) external returns (bool) {
uint256 currentAllowance = allowance[from][msg.sender];
if (currentAllowance != type(uint256).max) {
_approve(from, msg.sender, currentAllowance - value);
}
_burn(from, value);
return true;
}
function burn(uint256 value) external returns (bool) {
_burn(msg.sender, value);
return true;
}
function mint(address to, uint256 value) external returns (bool) {
require(to != address(this) && to != address(0), "Invalid recipient");
balanceOf[to] += value;
totalSupply += value;
emit Transfer(address(0), to, value);
return true;
}
function DOMAIN_SEPARATOR() external view returns (bytes32) {
return _domainSeparator();
}
}
library Address {
function isContract(address account) internal view returns (bool) {
uint256 size;
assembly {
size := extcodesize(account)
}
return size > 0;
}
}

Foundry Test SetUp

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {StabilityPool} from "../src/RAACProtocol/StabilityPool.sol";
import {LendingPool} from "../src/RAACProtocol/LendingPool.sol";
import {RToken} from "../src/RAACProtocol/tokens/RToken.sol";
import {DebtToken} from "../src/RAACProtocol/tokens/DebtToken.sol";
import {DEToken} from "../src/RAACProtocol/tokens/DEToken.sol";
import {CrvUSDMock} from "./CrvUSDMock.sol";
import {RAACNFT} from "../src/RAACProtocol/tokens/RAACNFT.sol";
import {RAACToken} from "../src/RAACProtocol/tokens/RAACToken.sol";
import {RAACMinter} from "../src/RAACProtocol/RAACMinter.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {RAACHousePrices} from "./OracleMock.sol";
import {WadRayMath} from "../src/RAACProtocol/libraries/WadRayMath.sol";
contract StabilityAndLendingPoolTest is Test {
LendingPool public lendingPool;
StabilityPool public stabilityPool;
RToken public rToken;
DebtToken public debtToken;
DEToken public deToken;
CrvUSDMock public crvUSD;
RAACHousePrices public raacHousePrices;
RAACNFT public raacNFT;
RAACToken public raacToken;
RAACMinter public raacMinter;
address deployer = makeAddr("deployer");
address Alice = makeAddr("Alice");
address Bob = makeAddr("Bob");
address Eve = makeAddr("Eve");
address Steve = makeAddr("Steve");
function setUp() public {
vm.warp(10 days);
vm.roll(140000);
uint256 initialPrimeRate = 1 * 1e26;
uint256 initialTax = 500;
vm.startPrank(deployer);
// Deploy base contracts
crvUSD = new CrvUSDMock("CrvUSD Token", "CrvUSD");
rToken = new RToken("RToken", "R", deployer, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", deployer);
deToken = new DEToken("DEToken", "DE", deployer, address(rToken));
raacHousePrices = new RAACHousePrices();
raacNFT = new RAACNFT(address(crvUSD), address(raacHousePrices), deployer);
raacToken = new RAACToken(deployer, initialTax, initialTax);
// Deploy LendingPool
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
initialPrimeRate
);
// Deploy StabilityPool implementation
StabilityPool stabilityPoolImplementation = new StabilityPool(deployer);
// Initialize StabilityPool with temporary minter
bytes memory initData = abi.encodeWithSelector(
StabilityPool.initialize.selector,
address(rToken),
address(deToken),
address(raacToken),
address(makeAddr("RAACMinter")),
address(crvUSD),
address(lendingPool)
);
// Deploy proxy with implementation
ERC1967Proxy proxy = new ERC1967Proxy(address(stabilityPoolImplementation), initData);
stabilityPool = StabilityPool(address(proxy));
// Deploy RAACMinter
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), deployer);
// Set up relationships between contracts
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
lendingPool.setStabilityPool(address(stabilityPool));
stabilityPool.setRAACMinter(address(raacMinter));
raacToken.setMinter(address(raacMinter));
// Whitelist key addresses to bypass RAAC token tax
raacToken.manageWhitelist(address(stabilityPool), true);
raacToken.manageWhitelist(address(raacMinter), true);
vm.stopPrank();
}
}

PoC

function testLendersLoseDepositedCrvUSDAndAccruedInterestDueToIncorrectCalculationInRToken() public {
// Minting Eve crvUSD tokens to be able to repay borrowed amount + accrued interest
mintCrvUSDToUser(Eve, 1000 ether);
// Minting Steve crvUSD tokens to be able to repay borrowed amount + accrued interest
mintCrvUSDToUser(Steve, 1000 ether);
// Bob deposit Amount
uint256 bobDepositAmount = 1_000 ether;
// Alice deposit Amount
uint256 aliceDepositAmount = 3_000 ether;
console.log("...........................Deposit Bob LendingPool...........................");
// Bob deposits 1_000 tokens into the LendingPool
userDepositIntoLendingPool(Bob, bobDepositAmount);
// Alice deposits 3_000 tokens into the LendingPool
console.log("...........................Deposit Alice LendingPool...........................");
userDepositIntoLendingPool(Alice, aliceDepositAmount);
console.log("...........................Deposit Bob StabilityPool...........................");
uint256 bobRTokenBalance = rToken.balanceOf(Bob);
// Bob deposits all of his RTokens in the StabilityPool
userDepositIntoStabilityPool(Bob, bobRTokenBalance);
// Asserting that Bob has no remaining RTokens
assert(rToken.balanceOf(Bob) == 0);
console.log("...........................Deposit Alice StabilityPool...........................");
uint256 aliceRTokenBalance = rToken.balanceOf(Alice);
// Alice deposits all of his RTokens in the StabilityPool
userDepositIntoStabilityPool(Alice, aliceRTokenBalance);
// Asserting that Alice has no remaining RTokens
assert(rToken.balanceOf(Alice) == 0);
console.log(".....................Eve Mint NFT And Deposit In LendingPool.....................");
// Minting Eve an RAACNFT with id: 1, worth 10_000 crvUSD
userMintRAACNFTAndDepositIntoLendingPool(Eve, 1);
console.log(".....................Steve Mint NFT And Deposit In LendingPool.....................");
// Minting Steve an RAACNFT with id: 2, worth 10_000 crvUSD
userMintRAACNFTAndDepositIntoLendingPool(Steve, 2);
// The amount of crvUSD that Eve is going to borrow
uint256 eveBorrowAmount = 700 ether;
console.log("....................Eve crvUSD Against NFT Collateral.....................");
// Eve borrows 700 crvUSD from the LendingPool
userBorrowAgainstCollateralNFT(Eve, eveBorrowAmount);
// The amount of crvUSD that Steve is going to borrow
uint256 steveBorrowAmount = 300 ether;
console.log("....................Steve crvUSD Against NFT Collateral.....................");
// Steve borrows 300 crvUSD from the LendingPool
userBorrowAgainstCollateralNFT(Steve, steveBorrowAmount);
// 10 days pass
vm.warp(block.timestamp + 10 days);
vm.roll(block.number + 10 * 12_000);
// Updating the state
lendingPool.updateState();
// The initial borrowed amoutn of Eve + the accrued interest
uint256 eveRepayingAmount = debtToken.balanceOf(Eve);
console.log("...........................Eve Repay Borrowed Amount...........................");
// Eve repays all of the debt she has to
userRepayBorrowedAmount(Eve, eveRepayingAmount);
// Asserting that Eve has no debt left
assert(lendingPool.getUserDebt(Eve) == 0);
// Updating the state
lendingPool.updateState();
// 5 days pass
vm.warp(block.timestamp + 5 days);
vm.roll(block.number + 5 * 12_000);
console.log("........................Bob Withdraw from StabilityPool........................");
// Getting Bob's DEToken balance
uint256 bobDETokenBalance = deToken.balanceOf(Bob);
// Bob withdraws his entire DEToken balance from the StabiliyPool
userWithdrawFromStabilityPool(Bob, bobDETokenBalance);
// Asserting that Bob has no DETokens left
assert(deToken.balanceOf(Bob) == 0);
console.log("........................Bob Withdraw from LendingPool........................");
// Bob withdraws his initial deposited amount + 10 tokens (to get his interest accrued)
// `+ 10` is for safety because the in `RToken::burn` function if the amount exceeds the user's balance
// the amount is assigned to the user's balance:
// if (amount > userBalance) {
// amount = userBalance;
// }
userWithdrawDepositedAmountFromLendingPool(Bob, bobDepositAmount + 10 ether);
// Getting Bob's rToken token balance after final withdrawal from the LendingPool
uint256 bobRTokenBalanceAfterWithdrawal = rToken.balanceOf(Bob);
// Asserting that Bob has no rTokens left to withdraw
assert(bobRTokenBalanceAfterWithdrawal == 0);
// Getting Bob's final crvUSD token balance
uint256 bobFinalcrvUSDBalance = crvUSD.balanceOf(address(crvUSD));
// Asserting that not only Bob got no interest from Eve and Steve, but even lost tokens from his
// initial depoist
assert(bobDepositAmount > bobFinalcrvUSDBalance);
// See console.logs to see how many tokens Bob gets:
// Spoiler: Bob crvUSD Balance: 999701276659239476496
console.log("................TEST FINAL VALUES................");
console.log("Bob crvUSD Balance:", crvUSD.balanceOf(Bob));
console.log("Bob rToken Balance:", rToken.balanceOf(Bob));
console.log(".......................");
console.log("RToken Contract crvUSD Balance: ", crvUSD.balanceOf(address(rToken)));
}

Helper Functions

function userDepositIntoLendingPool(address user, uint256 amount) public {
vm.startPrank(user);
crvUSD.mint(user, amount);
crvUSD.approve(address(lendingPool), amount);
lendingPool.deposit(amount);
vm.stopPrank();
}
function userDepositIntoStabilityPool(address user, uint256 amount) public {
vm.startPrank(user);
rToken.approve(address(stabilityPool), amount);
stabilityPool.deposit(amount);
vm.stopPrank();
}
function userMintRAACNFTAndDepositIntoLendingPool(address user, uint256 id) public {
vm.startPrank(user);
uint256 amount = 10_000 ether;
crvUSD.mint(user, amount);
crvUSD.approve(address(raacNFT), amount);
raacNFT.mint(id, amount);
vm.warp(block.timestamp + 1 days);
vm.roll(block.number + 12_000);
raacNFT.approve(address(lendingPool), id);
lendingPool.depositNFT(id);
vm.stopPrank();
}
function userBorrowAgainstCollateralNFT(address user, uint256 amount) public {
vm.startPrank(user);
lendingPool.borrow(amount);
vm.stopPrank();
}
function userRepayBorrowedAmount(address user, uint256 amount) public {
vm.startPrank(user);
crvUSD.approve(address(lendingPool), amount);
lendingPool.repay(amount);
vm.stopPrank();
}
function userWithdrawDepositedAmountFromLendingPool(address user, uint256 amount) public {
vm.startPrank(user);
rToken.approve(address(lendingPool), amount);
lendingPool.withdraw(amount);
vm.stopPrank();
}
function userWithdrawFromStabilityPool(address user, uint256 amount) public {
vm.startPrank(user);
deToken.approve(address(stabilityPool), amount);
stabilityPool.withdraw(amount);
vm.stopPrank();
}
function mintCrvUSDToUser(address user, uint256 amount) public {
crvUSD.mint(user, amount);
}

Recommendations

Change the RToken::transfer function as shown:

/**
* @dev Overrides the ERC20 transfer function to use scaled amounts
* @param recipient The recipient address
* @param amount The amount to transfer (in underlying asset units)
*/
function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
- uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
+ uint256 scaledAmount = amount.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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