Core Contracts

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

Pool's total usage/debt is wrongly tracked

Summary

The function DebtToken::totalSupply() implementation is incorrect that can cause the reserve total usage is tracked wrongly.

Vulnerability Details

The function DebtToken::totalSupply() returns the wrong value such that it returns scaled_supply / normalized_debt. It should be scaled_supply * normalized_debt instead

/// DebtToken
function totalSupply() public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledSupply = super.totalSupply();
@> return scaledSupply.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
}

It can result the LendingPool to track the wrong total usage

// LendingPool
function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
...
// Mint DebtTokens to the user (scaled amount)
@> (bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
// Transfer borrowed amount to user
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
user.scaledDebtBalance += scaledAmount;
// reserve.totalUsage += amount;
@> reserve.totalUsage = newTotalSupply;
...
}
function _repay(uint256 amount, address onBehalfOf) internal {
...
// Burn DebtTokens from the user whose debt is being repaid (onBehalfOf)
// is not actualRepayAmount because we want to allow paying extra dust and we will then cap there
@> (uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
// Transfer reserve assets from the caller (msg.sender) to the reserve
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
@> reserve.totalUsage = newTotalSupply;
user.scaledDebtBalance -= amountBurned;
...
}

Hence, there will be wrong reserve parameters computed, such as utilization rate, borrow rate and liquidity rate -> also affect liquidity index and usage index. It means that the whole lending protocol is broken.

/// ReserveLibrary
function updateInterestRatesAndLiquidity(ReserveData storage reserve,ReserveRateData storage rateData,uint256 liquidityAdded,uint256 liquidityTaken) internal {
// Update total liquidity
if (liquidityAdded > 0) {
reserve.totalLiquidity = reserve.totalLiquidity + liquidityAdded.toUint128();
}
if (liquidityTaken > 0) {
if (reserve.totalLiquidity < liquidityTaken) revert InsufficientLiquidity();
reserve.totalLiquidity = reserve.totalLiquidity - liquidityTaken.toUint128();
}
uint256 totalLiquidity = reserve.totalLiquidity;
uint256 totalDebt = reserve.totalUsage;
uint256 computedDebt = getNormalizedDebt(reserve, rateData);
uint256 computedLiquidity = getNormalizedIncome(reserve, rateData);
// Calculate utilization rate
@> uint256 utilizationRate = calculateUtilizationRate(reserve.totalLiquidity, reserve.totalUsage);
// Update current usage rate (borrow rate)
@> rateData.currentUsageRate = calculateBorrowRate(
rateData.primeRate,
rateData.baseRate,
rateData.optimalRate,
rateData.maxRate,
rateData.optimalUtilizationRate,
@> utilizationRate // incorrect UR used
);
// Update current liquidity rate
@> rateData.currentLiquidityRate = calculateLiquidityRate(
@> utilizationRate, // incorrect UR used
rateData.currentUsageRate,
rateData.protocolFeeRate,
totalDebt
);
// Update the reserve interests
updateReserveInterests(reserve, rateData);
emit InterestRatesUpdated(rateData.currentLiquidityRate, rateData.currentUsageRate);
}
function calculateUtilizationRate(uint256 totalLiquidity, uint256 totalDebt) internal pure returns (uint256) {
if (totalLiquidity < 1) {
return WadRayMath.RAY; // 100% utilization if no liquidity
}
@> uint256 utilizationRate = totalDebt.rayDiv(totalLiquidity + totalDebt).toUint128();
return utilizationRate;
}
function updateReserveInterests(ReserveData storage reserve,ReserveRateData storage rateData) internal {
uint256 timeDelta = block.timestamp - uint256(reserve.lastUpdateTimestamp);
if (timeDelta < 1) {
return;
}
uint256 oldLiquidityIndex = reserve.liquidityIndex;
if (oldLiquidityIndex < 1) revert LiquidityIndexIsZero();
// 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
);
// Update the last update timestamp
reserve.lastUpdateTimestamp = uint40(block.timestamp);
emit ReserveInterestsUpdated(reserve.liquidityIndex, reserve.usageIndex);
}

PoC

Add this test to file test/unit/core/pools/LendingPool/LendingPool.test.js

it.only("test total usage/debt", async function(){
// @audit total usage/debt incorrect
// user1 borrows
const borrowAmount = ethers.parseEther("60");
await lendingPool.connect(user1).borrow(borrowAmount);
// time passes
await ethers.provider.send("evm_increaseTime", [30 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
// user2 borrow
await raacHousePrices.setHousePrice(2, ethers.parseEther("100"));
const amountToPay = ethers.parseEther("100");
await token.mint(user2.address, amountToPay);
await token.connect(user2).approve(raacNFT.target, amountToPay);
const tokenId = 2;
await raacNFT.connect(user2).mint(tokenId, amountToPay);
await raacNFT.connect(user2).approve(lendingPool.target, tokenId);
await lendingPool.connect(user2).depositNFT(tokenId);
await lendingPool.connect(user2).borrow(ethers.parseEther("1"));
// time passes
await ethers.provider.send("evm_increaseTime", [30 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
let sumUsersDebt = await debtToken.balanceOf(user1.address) + await debtToken.balanceOf(user2.address)
let totalSupply = await debtToken.totalSupply();
let reserve = await lendingPool.reserve();
console.log(`--> before updating state\ntotal debt\t${sumUsersDebt}\ntotal supply\t${totalSupply}\nreserve total usage\t${reserve[4]}`)
// update state
await lendingPool.connect(user1).updateState();
totalSupply = await debtToken.totalSupply();
reserve = await lendingPool.reserve();
console.log(`\n--> after updating state\ntotal debt\t${sumUsersDebt}\ntotal supply\t${totalSupply}\nreserve total usage\t${reserve[4]}`)
})

Run the test and console show:

LendingPool
Borrow and Repay
--> before updating state
total debt 61148689538325432940
total supply 60846740072285777813
reserve total usage 60846740072285777813
--> after updating state
total debt 61148689538325432940
total supply 60695961312110307280
reserve total usage 60846740072285777813
test total usage/debt (39ms)
1 passing (2s)

It means that the total usage is incorrect when compared with the total debt sum from all positions

Impact

  • Critical lending parameters are incorrectly computed -> Broken the whole lending system

Tools Used

Manual

Recommendations

function totalSupply() public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledSupply = super.totalSupply();
- return scaledSupply.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
+ return scaledSupply.rayMul(ILendingPool(_reservePool).getNormalizedDebt());
}
Updates

Lead Judging Commences

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

DebtToken::totalSupply incorrectly uses rayDiv instead of rayMul, severely under-reporting total debt and causing lending protocol accounting errors

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

DebtToken::totalSupply incorrectly uses rayDiv instead of rayMul, severely under-reporting total debt and causing lending protocol accounting errors

Support

FAQs

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