Core Contracts

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

Lending pool reserve liquidity can be incorrectly accounted due to transferring accrued dust

Summary

Lending pool reserve liquidity can be incorrectly accounted due to transferring accrued dust.

Vulnerability Details

Lending pool borrower's debt accruing is compounding, whereas the liquidity rate is linear.

ReserveLibrary::updateReserveInterests()

// Update liquidity index using linear interest
reserve.liquidityIndex = calculateLiquidityIndex(
rateData.currentLiquidityRate,
timeDelta,
reserve.liquidityIndex
);
// Update usage index (debt index) using compounded interest
reserve.usageIndex = calculateUsageIndex(
rateData.currentUsageRate,
timeDelta,
reserve.usageIndex
);

As such, lender's profit is less than the interest paid by the borrower, the transferAccruedDust() exists so those funds can be collected.

LendingPool::transferAccruedDust()

function transferAccruedDust(address recipient, uint256 amount) external onlyOwner {
// update state
ReserveLibrary.updateReserveState(reserve, rateData);
require(recipient != address(0), "LendingPool: Recipient cannot be zero address");
IRToken(reserve.reserveRTokenAddress).transferAccruedDust(recipient, amount);
}

However, reserve.totalLiquidity is not updated accordingly, this can lead to an incorrect accounting of reserve total liquidity.

Impact

reserve.totalLiquidity is used to calculate utilization rate, incorrect utilizatio rate leads to incorrect borrower rate, and borrowers pays more/less interest than expected.

POC

Please run forge test --mt testAudit_LiquidityIsIncorrectlyAccounted:

// 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));
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_LiquidityIsIncorrectlyAccounted() 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();
// Withdraw liquidity
vm.prank(alice);
lendingPool.withdraw(type(uint256).max);
(, , , uint256 totalLiquidityBefore, , , , ) = lendingPool.reserve();
uint256 underlyinCrvUsdBalanceBefore = crvUSD.balanceOf(address(rToken));
// Transfer accrued dust
vm.prank(owner);
lendingPool.transferAccruedDust(owner, type(uint256).max);
(, , , uint256 totalLiquidityAfter, , , , ) = lendingPool.reserve();
uint256 underlyinCrvUsdBalanceAfter = crvUSD.balanceOf(address(rToken));
// Initially, reserve liquidity equals to the underlying CrvUSD balance,
// however, after transferring accrued dust,
// underlying CrvUSD balance decreased but reserve liquidty does not change
assertEq(totalLiquidityBefore, underlyinCrvUsdBalanceBefore);
assertGt(underlyinCrvUsdBalanceBefore, underlyinCrvUsdBalanceAfter);
assertEq(totalLiquidityBefore, totalLiquidityAfter);
}
}

Tools Used

Manual Review

Recommendations

When transfers accrued dust, reserve.totalLiquidity should be updated accordingly.

function transferAccruedDust(address recipient, uint256 amount) external onlyOwner {
// update state
ReserveLibrary.updateReserveState(reserve, rateData);
require(recipient != address(0), "LendingPool: Recipient cannot be zero address");
IRToken(reserve.reserveRTokenAddress).transferAccruedDust(recipient, amount);
+ // `amount` here should be the actually transferred amount
+ ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amount, 0);
}
Updates

Lead Judging Commences

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

LendingPool::transferAccruedDust doesn't update reserve.totalLiquidity when dust is transferred, causing discrepancy between tracked and actual liquidity

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

LendingPool::transferAccruedDust doesn't update reserve.totalLiquidity when dust is transferred, causing discrepancy between tracked and actual liquidity

Support

FAQs

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

Give us feedback!