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:
Bob and Alice deposit crvUSD
into the LendingPool
and get minted RTokens
Bob and Alice depoist these RTokens
into the StabilityPool
and get minted DETokens
.
Eve and Steve deposit their RAACNFT
as collateral and borrow some of Bob and Alice's tokens
Bob and Alice's tokens accrue interest
After some time Eve and Steve decide to repay the full borrowed amount + the interest accrued
Bob and Alice withdraw their RTokens
by calling StabilityPool::withdraw
which burns their DETokens
and gives them the RAACToken
rewards.
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
:
Bob and Alice receive a slightly less amount of RTokens
than they are supposed to.
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
Direct financial loss for lenders.
Permanent loss of accrued interest in the LendingPool
Partial loss of initial deposits.
Tools Used
Manual Review, Foundry Test
PoC
To set up the test environment:
Run foundryup
Run foundry init
Run forge install OpenZeppelin/openzeppelin-contracts --no-git
and forge install https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable --no-git
Paste this in foundry.toml
- remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts", "@openzeppelin/contracts-upgradeable=lib/openzeppelin-contracts-upgradeable/contracts"]
Then create a folder in src
called RAACProtocol
.
In the RAACProtocol
create two folders called: interfaces
, libraries
and tokens
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
Into the libraries
paste the following contracts:
Libraries
PercentageMath.sol,
ReserveLibrary.sol,
WadRayMath.sol
Into the tokens
paste the following contracts:
Tokens
DebtToken.sol,
DEToken.sol,
RAACNFT.sol,
RAACToken.sol,
RToken.sol
Create files in the test folder for the mocks and paste them.
Create file in the test folder called RAACProtocolTest.t.sol
.
Paste the test setup.
Paste the test and the helper functions inside the StabilityAndLendingPoolTest
contract.
Mocks
Oracle Mock
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;
}
}
CrvUSD Mock
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;
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
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);
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);
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
initialPrimeRate
);
StabilityPool stabilityPoolImplementation = new StabilityPool(deployer);
bytes memory initData = abi.encodeWithSelector(
StabilityPool.initialize.selector,
address(rToken),
address(deToken),
address(raacToken),
address(makeAddr("RAACMinter")),
address(crvUSD),
address(lendingPool)
);
ERC1967Proxy proxy = new ERC1967Proxy(address(stabilityPoolImplementation), initData);
stabilityPool = StabilityPool(address(proxy));
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), deployer);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
lendingPool.setStabilityPool(address(stabilityPool));
stabilityPool.setRAACMinter(address(raacMinter));
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
raacToken.manageWhitelist(address(raacMinter), true);
vm.stopPrank();
}
}
PoC
function testLendersLoseDepositedCrvUSDAndAccruedInterestDueToIncorrectCalculationInRToken() public {
mintCrvUSDToUser(Eve, 1000 ether);
mintCrvUSDToUser(Steve, 1000 ether);
uint256 bobDepositAmount = 1_000 ether;
uint256 aliceDepositAmount = 3_000 ether;
console.log("...........................Deposit Bob LendingPool...........................");
userDepositIntoLendingPool(Bob, bobDepositAmount);
console.log("...........................Deposit Alice LendingPool...........................");
userDepositIntoLendingPool(Alice, aliceDepositAmount);
console.log("...........................Deposit Bob StabilityPool...........................");
uint256 bobRTokenBalance = rToken.balanceOf(Bob);
userDepositIntoStabilityPool(Bob, bobRTokenBalance);
assert(rToken.balanceOf(Bob) == 0);
console.log("...........................Deposit Alice StabilityPool...........................");
uint256 aliceRTokenBalance = rToken.balanceOf(Alice);
userDepositIntoStabilityPool(Alice, aliceRTokenBalance);
assert(rToken.balanceOf(Alice) == 0);
console.log(".....................Eve Mint NFT And Deposit In LendingPool.....................");
userMintRAACNFTAndDepositIntoLendingPool(Eve, 1);
console.log(".....................Steve Mint NFT And Deposit In LendingPool.....................");
userMintRAACNFTAndDepositIntoLendingPool(Steve, 2);
uint256 eveBorrowAmount = 700 ether;
console.log("....................Eve crvUSD Against NFT Collateral.....................");
userBorrowAgainstCollateralNFT(Eve, eveBorrowAmount);
uint256 steveBorrowAmount = 300 ether;
console.log("....................Steve crvUSD Against NFT Collateral.....................");
userBorrowAgainstCollateralNFT(Steve, steveBorrowAmount);
vm.warp(block.timestamp + 10 days);
vm.roll(block.number + 10 * 12_000);
lendingPool.updateState();
uint256 eveRepayingAmount = debtToken.balanceOf(Eve);
console.log("...........................Eve Repay Borrowed Amount...........................");
userRepayBorrowedAmount(Eve, eveRepayingAmount);
assert(lendingPool.getUserDebt(Eve) == 0);
lendingPool.updateState();
vm.warp(block.timestamp + 5 days);
vm.roll(block.number + 5 * 12_000);
console.log("........................Bob Withdraw from StabilityPool........................");
uint256 bobDETokenBalance = deToken.balanceOf(Bob);
userWithdrawFromStabilityPool(Bob, bobDETokenBalance);
assert(deToken.balanceOf(Bob) == 0);
console.log("........................Bob Withdraw from LendingPool........................");
userWithdrawDepositedAmountFromLendingPool(Bob, bobDepositAmount + 10 ether);
uint256 bobRTokenBalanceAfterWithdrawal = rToken.balanceOf(Bob);
assert(bobRTokenBalanceAfterWithdrawal == 0);
uint256 bobFinalcrvUSDBalance = crvUSD.balanceOf(address(crvUSD));
assert(bobDepositAmount > bobFinalcrvUSDBalance);
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);
}