Summary
When DebtToken is minted, it checks user's accrued debt interest from previous loan and increases mint amount by accrued debt.
However, this calculation is not necessary because accrued debt is already reflected on usageIndex
increase.
This unnecessary calculation brings multiple issues including the following:
Vulnerability Details
Root Cause Analysis
Debt Inflation
Let's check how accrued debt interest is calculated in DebtToken.mint
:
uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
uint256 amountToMint = amount + balanceIncrease;
_mint(onBehalfOf, amountToMint.toUint128());
Also do notice that balanceOf
is calculated as the following:
function balanceOf(address account) public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledBalance = super.balanceOf(account);
return scaledBalance.rayMul(ILendingPool(_reservePool).getNormalizedDebt());
}
And when DebtToken is minted, ERC20's underlying balance is calculated as the follows:
function _update(address from, address to, uint256 amount) internal virtual override {
if (from != address(0) && to != address(0)) {
revert TransfersNotAllowed();
}
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
super._update(from, to, scaledAmount);
emit Transfer(from, to, amount);
}
To avoid mixed usage of scaledBalance
and scaledAmount
, let's agree on some terms here:
underlyingBalance
is ERC20's native balance, i.e. underlyingBalance = DebtToken.balanceOf(user) / usageIndex
scaledBalance
is DebtToken specific balance, i.e. scaledBalance = DebtToken.balanceOf(user)
Consider the following scenario:
-
Current usageIndex
is 1
-
A user borrows 1000 USD from LendingPool
-
usageIndex
increased to 2
-
Same user borrows 1000 USD again from LendingPool
scaledBalance
is 1000 * usageIndex = 2000
balanceIncrease
is scaledBalance * usageIndex - scaledBalance * previousUsageIndex = 2000 * 2 - 2000 * 1 = 2000
amountToMint
is amount + balanceIncrease = 1000 + 2000 = 3000
Increased underlyingBalance
is amountToMint / usageIndex = 3000 / 2 = 1500
New underlyingBalance
is 1000 + 1500 = 2500
User's DebtToken balance is underlyingBalance * usageIndex = 2500 * 2 = 5000
Now check what this means in real world scenario:
The user initially had 1000 USD debt
After usageIndex
increased to 2, the user now has 2000 USD debt
The user borrowed 1000 USD debt again
The user's total debt amount is 3000 USD
But according to DebtToken balance, the user's debt is 5000 USD.
You may ask, does DebtToken balance represents user's debt in USD? Yes, it is.
Check LendingPool._repay
function:
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
@> IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
The LendingPool burns amount
of DebtToken in change of transfeerring amountScaled
to RToken.
So what is this amountScaled
value?
Check DebtToken.burn
implementation and you can see amountScaled
is just the amount
of asset user transferred to LendingPool:
function burn(
address from,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256, uint256) {
if (from == address(0)) revert InvalidAddress();
if (amount == 0) {
return (0, totalSupply(), 0, 0);
}
uint256 userBalance = balanceOf(from);
uint256 balanceIncrease = 0;
if (_userState[from].index != 0 && _userState[from].index < index) {
uint256 borrowIndex = ILendingPool(_reservePool).getNormalizedDebt();
balanceIncrease = userBalance.rayMul(borrowIndex) - userBalance.rayMul(_userState[from].index);
@> amount = amount;
}
_userState[from].index = index.toUint128();
if(amount > userBalance){
@> amount = userBalance;
}
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
_burn(from, amount.toUint128());
emit Burn(from, amountScaled, index);
@> return (amount, totalSupply(), amountScaled, balanceIncrease);
}
So the conclusion is user's debt is inflated when user borrows for the second time.
This brings another side effect: second-time borrowers can never repay their full debt.
Repay Failure
When user borrows from LendingPool, the pool will increase user's unscaledDebtBalance
:
uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
user.scaledDebtBalance += scaledAmount;
When user repays their debt, unscaledDebtBalance
is decreased by burnt amount:
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
reserve.totalUsage = newTotalSupply;
user.scaledDebtBalance -= amountBurned;
The problem arises if the user is a second-time borrower.
If we take a look into DebtToken.mint
function:
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
uint256 amountToMint = amount + balanceIncrease;
_mint(onBehalfOf, amountToMint.toUint128());
For a second-time borrower, current usageIndex
is greater than usageIndex
at borrowed time. So actual amountToMint
will be increased by accured debt interest.
In this case, DebtToken's unscaled balance will be greater than LendingPool's unscaledDebtBalance
.
In order to repay full debt, the borrower needs to burn more unscaled balance than LendingPool's unscaledDebtBalance
Thus, the following line will revert with underflow error
user.scaledDebtBalance -= amountBurned;
POC
Secnario
Borrower takes a loan of 10_000 USD
10 days pass
Borrower debt is around 10_009 USD
Borrower takes another loan of 10_000 USD
Borrower debt is 20_018 USD. However correct amount is 20_009USD
Borrower tries to repay their full debt (20_018) anyway, but it reverts with underflow
How to run POC
pragma solidity ^0.8.19;
import "../lib/forge-std/src/Test.sol";
import {RToken} from "../contracts/core/tokens/RToken.sol";
import {DebtToken} from "../contracts/core/tokens/DebtToken.sol";
import {DEToken} from "../contracts/core/tokens/DEToken.sol";
import {RAACToken} from "../contracts/core/tokens/RAACToken.sol";
import {RAACNFT} from "../contracts/core/tokens/RAACNFT.sol";
import {LendingPool} from "../contracts/core/pools/LendingPool/LendingPool.sol";
import {StabilityPool} from "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {RAACMinter} from "../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {crvUSDToken} from "../contracts/mocks/core/tokens/crvUSDToken.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract RAACHousePricesMock {
mapping(uint256 => uint256) public prices;
function getLatestPrice(uint256 tokenId) external view returns (uint256, uint256) {
return (prices[tokenId], block.timestamp);
}
function setTokenPrice(uint256 tokenId, uint256 price) external {
prices[tokenId] = price;
}
function tokenToHousePrice(uint256 tokenId) external view returns (uint256) {
return prices[tokenId];
}
}
contract DebtTokenTest is Test {
RToken rtoken;
DebtToken debtToken;
RAACToken raacToken;
DEToken deToken;
RAACNFT raacNft;
RAACMinter raacMinter;
crvUSDToken asset;
LendingPool lendingPool;
StabilityPool stabilityPool;
RAACHousePricesMock housePrice;
address depositor = makeAddr("depositor");
address borrower = makeAddr("borrower");
address user = makeAddr("user");
uint256 tokenId = 1;
uint256 userAssetAmount = 10_000e18;
uint256 nftPrice = 50_000e18;
uint256 initialBurnTaxRate = 50;
uint256 initialSwapTaxRate = 100;
uint256 initialPrimeRate = 0.1e27;
function setUp() external {
vm.warp(1e9);
asset = new crvUSDToken(address(this));
housePrice = new RAACHousePricesMock();
debtToken = new DebtToken("DebtToken", "DTK", address(this));
rtoken = new RToken("RToken", "RTK", address(this), address(asset));
raacNft = new RAACNFT(address(asset), address(housePrice), address(this));
lendingPool = new LendingPool(
address(asset), address(rtoken), address(debtToken), address(raacNft), address(housePrice), 0.1e27
);
rtoken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken = new DEToken("DEToken", "DET", address(this), address(rtoken));
raacToken = new RAACToken(address(this), initialSwapTaxRate, initialBurnTaxRate);
raacToken.setMinter(address(this));
stabilityPool = new StabilityPool(address(this));
stabilityPool.initialize(
address(rtoken), address(deToken), address(raacToken), address(this), address(asset), address(lendingPool)
);
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), address(this));
stabilityPool.setRAACMinter(address(raacMinter));
deToken.setStabilityPool(address(stabilityPool));
uint256 depositorAmount = userAssetAmount * 10;
deal(address(asset), depositor, depositorAmount);
vm.startPrank(depositor);
asset.approve(address(lendingPool), depositorAmount);
lendingPool.deposit(depositorAmount);
uint256 rtokenBalance = rtoken.balanceOf(depositor);
rtoken.approve(address(stabilityPool), rtokenBalance);
stabilityPool.deposit(rtokenBalance);
vm.stopPrank();
housePrice.setTokenPrice(tokenId, nftPrice);
deal(address(asset), borrower, nftPrice);
vm.startPrank(borrower);
asset.approve(address(raacNft), nftPrice);
raacNft.mint(tokenId, nftPrice);
raacNft.approve(address(lendingPool), tokenId);
lendingPool.depositNFT(tokenId);
lendingPool.borrow(userAssetAmount);
vm.stopPrank();
}
function testUsageIndex() external {
skip(10 days);
lendingPool.updateState();
emit log_named_decimal_uint("user debt", debtToken.balanceOf(borrower), 18);
vm.startPrank(borrower);
lendingPool.borrow(userAssetAmount);
vm.stopPrank();
emit log_named_decimal_uint("user debt", debtToken.balanceOf(borrower), 18);
uint256 debt = debtToken.balanceOf(borrower);
deal(address(asset), borrower, debt);
vm.startPrank(borrower);
asset.approve(address(lendingPool), debt);
vm.expectRevert(stdError.arithmeticError);
lendingPool.repay(debt);
vm.stopPrank();
}
}
Impact
Users who borrows from LendingPool for multiple times will get unfair accrued debt than linear rate.
Second-time borrowers won't be able to pay their full debt amount, due to underflow
LendingPool.getUserDebt()
will return different value than DebtToken.balanceOf(user)
Tools Used
Manual Review, Foundry
Recommendations
balanceIncrease
calculation is not necessary, as how it is ignored in DebtToken.burn
function:
@@ -150,11 +150,6 @@ contract DebtToken is ERC20, ERC20Permit, IDebtToken, Ownable {
uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
uint256 balanceIncrease = 0;
- if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
- balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
- }
-
_userState[onBehalfOf].index = index.toUint128();
uint256 amountToMint = amount + balanceIncrease;
Rationale on why accrued debt calculation is unnecessary
Because accrued debt is already reflected on usageIndex
.
If that's hard to understand, let's do another thought experiment. This time, we will not consider balanceIncrease
:
-
Current usageIndex
is 1
-
A user borrows 1000 USD from LendingPool
-
usageIndex
increased to 2
-
Same user borrows 1000 USD again from LendingPool
amountToMint
is 1000
Increased underlyingBalance
is amountToMint / usageIndex = 1000 / 2 = 500
New underlyingBalance
is 1000 + 500 = 1500
User's DebtToken balance is underlyingBalance * usageIndex = 1500 * 2 = 3000
This tallys perfectly with real-world scenario calculation.