The DebtToken contract and LendingPool interaction contains multiple arithmetic calculation errors related to scaling balances. These issues occur in three core functions: borrow(), mint(), _update() and repay(). The combination of these issues leads to incorrect debt accounting, where users' debt balances are improperly calculated and scaled, affecting the entire lending protocol's accounting system.
The user can not clear his full debt amount (because the _repay() function will revert) and accumulates more and more Debt tokens with each time he borrows.
The totalUsage of the LendingPool will accumulate over time resulting in incorrect rate calculations.
The vulnerability stems from four interconnected issues in the debt token accounting system:
The issue can be reproduced using the test provided below and by checking the printed out logs below the Test:
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {StabilityPool} from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {crvUSDToken} from "../../contracts/mocks/core/tokens/crvUSDToken.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {RAACHousePrices} from "../../contracts/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "../../contracts/core/tokens/RAACNFT.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 {LendingPool} from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import {RAACMinter, IRAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {PercentageMath} from "../../contracts/libraries/math/PercentageMath.sol";
import {ILendingPool} from "../../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
import {IStabilityPool} from "../../contracts/interfaces/core/pools/StabilityPool/IStabilityPool.sol";
import {WadRayMath} from "../../contracts/libraries/math/WadRayMath.sol";
import {stdError} from "forge-std/StdError.sol";
contract FoundryTest is Test {
using PercentageMath for uint256;
using WadRayMath for uint256;
StabilityPool public stabilityPool;
LendingPool public lendingPool;
RAACMinter public raacMinter;
crvUSDToken public crvusd;
RToken public rToken;
DEToken public deToken;
RAACToken public raacToken;
RAACNFT public raacNFT;
DebtToken public debtToken;
RAACHousePrices public raacHousePrices;
address public owner;
uint256 public constant INITIAL_BALANCE = 1000e18;
uint256 public constant INITIAL_PRIME_RATE = 1e27;
uint256 constant INITIAL_BATCH_SIZE = 3;
uint256 constant HOUSE_PRICE = 100e18;
uint256 constant TOKEN_ID = 1;
function setUp() public {
owner = address(this);
crvusd = new crvUSDToken(owner);
crvusd.setMinter(owner);
raacToken = new RAACToken(owner, 100, 50);
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner);
raacHousePrices.setHousePrice(TOKEN_ID, HOUSE_PRICE);
raacNFT = new RAACNFT(address(crvusd), address(raacHousePrices), owner);
rToken = new RToken("RToken", "RToken", owner, address(crvusd));
debtToken = new DebtToken("DebtToken", "DT", owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
lendingPool = new LendingPool(
address(crvusd),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
INITIAL_PRIME_RATE
);
stabilityPool = new StabilityPool(owner);
vm.warp(block.timestamp + 2 days);
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner);
lendingPool.setStabilityPool(address(stabilityPool));
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
deToken.transferOwnership(address(stabilityPool));
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(raacMinter),
address(crvusd),
address(lendingPool)
);
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
}
function test_ScalingIssues() public {
uint256 initialDeposit = 2000e18;
uint256 housePrice = 2000e18;
uint256 amountToBorrow = housePrice / 2;
raacHousePrices.setHousePrice(TOKEN_ID, housePrice);
address lender = makeAddr("lender");
address borrower = makeAddr("borrower");
crvusd.mint(lender, initialDeposit);
crvusd.mint(borrower, housePrice);
assertEq(crvusd.balanceOf(lender), initialDeposit);
assertEq(crvusd.balanceOf(borrower), housePrice);
assertEq(crvusd.balanceOf(address(rToken)), 0);
vm.startPrank(lender);
crvusd.approve(address(lendingPool), initialDeposit);
lendingPool.deposit(initialDeposit);
assertEq(crvusd.balanceOf(address(rToken)), initialDeposit);
vm.stopPrank();
uint256 usageIndexStart = lendingPool.getNormalizedDebt();
_printReserveState("at the beginning");
vm.startPrank(borrower);
crvusd.approve(address(raacNFT), housePrice);
raacNFT.mint(TOKEN_ID, housePrice);
assertEq(crvusd.balanceOf(borrower), 0);
assertEq(raacNFT.balanceOf(borrower), 1);
raacNFT.approve(address(lendingPool), TOKEN_ID);
lendingPool.depositNFT(TOKEN_ID);
assertEq(raacNFT.balanceOf(address(lendingPool)), 1);
lendingPool.borrow(amountToBorrow);
assertEq(crvusd.balanceOf(borrower), amountToBorrow);
_printReserveState("after first borrowing");
_printAddressBalance(borrower, "borrower");
vm.warp(block.timestamp + 10 days);
lendingPool.updateState();
uint256 newUsageIndex = lendingPool.getNormalizedDebt();
console2.log("\nNew Usage Index");
console2.log("newUsageIndex", newUsageIndex);
uint256 scaledBalance = debtToken.scaledBalanceOf(borrower);
uint256 expectedNewBalanceIncrease = scaledBalance.rayMul(newUsageIndex) -
scaledBalance.rayMul(usageIndexStart);
console2.log("\nExpected New Balance Increase");
console2.log("expectedNewBalanceIncrease", expectedNewBalanceIncrease);
uint256 expectedAmountToMint = amountToBorrow + expectedNewBalanceIncrease;
console2.log("\nExpected Amount To Mint");
console2.log("expectedAmountToMint", expectedAmountToMint);
uint256 debtTokenBalanceBefore = debtToken.balanceOf(borrower);
lendingPool.borrow(amountToBorrow);
vm.stopPrank();
uint256 expectedDebtTokenScaledBalanceAfter = debtTokenBalanceBefore + expectedAmountToMint;
console2.log("\nExpected Debt Token Scaled Balance After");
console2.log("expectedDebtTokenScaledBalanceAfter", expectedDebtTokenScaledBalanceAfter);
uint256 actualDebtTokenScaledBalanceAfter = debtToken.scaledBalanceOf(borrower);
console2.log("\nActual Debt Token Scaled Balance After");
console2.log("actualDebtTokenScaledBalanceAfter", actualDebtTokenScaledBalanceAfter);
uint256 actualScaledDebtInLendingPool = _getUserScaledDebtBalanceFromStorage(borrower);
console2.log("\nActual Scaled Debt In Lending Pool");
console2.log("actualScaledDebtInLendingPool", actualScaledDebtInLendingPool);
assertNotEq(actualDebtTokenScaledBalanceAfter, expectedDebtTokenScaledBalanceAfter);
assertNotEq(actualScaledDebtInLendingPool, expectedDebtTokenScaledBalanceAfter);
uint256 differenceInDebtTokenContract = expectedDebtTokenScaledBalanceAfter - actualDebtTokenScaledBalanceAfter;
console2.log("\nDifference In Debt Token Contract");
console2.log("differenceInDebtTokenContract", differenceInDebtTokenContract);
uint256 differenceInLendingPool = expectedDebtTokenScaledBalanceAfter - actualScaledDebtInLendingPool;
console2.log("\nDifference In Lending Pool");
console2.log("differenceInLendingPool", differenceInLendingPool);
_printReserveState("after second borrowing");
vm.startPrank(borrower);
crvusd.mint(borrower, 10000e18);
crvusd.approve(address(lendingPool), type(uint256).max);
vm.expectRevert(stdError.arithmeticError);
lendingPool.repay(type(uint256).max);
uint256 userDebt = lendingPool.getUserDebt(borrower);
lendingPool.repay(userDebt);
vm.stopPrank();
_printReserveState("after repayment");
_printAddressBalance(borrower, "borrower");
}
function _getUserScaledDebtBalanceFromStorage(address userAddress) internal view returns (uint256) {
bytes32 mapSlot = bytes32(uint256(18));
bytes32 slot = keccak256(abi.encode(userAddress, mapSlot));
bytes32 scaledDebtBalanceSlot = bytes32(uint256(slot));
bytes32 value = vm.load(address(lendingPool), scaledDebtBalanceSlot);
return uint256(value);
}
function _printAddressBalance(address addr, string memory label) internal view {
console2.log("\nPrinting address balance for:", label);
console2.log("crvUSD balance", crvusd.balanceOf(addr));
console2.log("Balance of DebtToken", debtToken.balanceOf(addr));
console2.log("scaledBalance DebtToken", debtToken.scaledBalanceOf(addr));
uint256 userScaledDebtBalance = _getUserScaledDebtBalanceFromStorage(addr);
console2.log("\nUser Scaled Debt Balance in LendingPool:");
console2.log("userScaledDebtBalance", userScaledDebtBalance);
console2.log("================================================");
}
function _printReserveState(string memory label) internal view {
(
address reserveRTokenAddr,
address reserveAssetAddr,
address reserveDebtTokenAddr,
uint256 totalLiquidity,
uint256 totalUsage,
uint128 liquidityIndex,
uint128 usageIndex,
uint40 lastUpdateTimestamp
) = lendingPool.reserve();
uint256 underlyingCrvUsdReserve = crvusd.balanceOf(address(rToken));
console2.log("\nReserve Data:", label);
console2.log("totalLiquidity", totalLiquidity);
console2.log("totalUsage", totalUsage);
console2.log("liquidityIndex", liquidityIndex);
console2.log("usageIndex", usageIndex);
console2.log("lastUpdateTimestamp", lastUpdateTimestamp);
console2.log("underlyingCrvUsdReserve", underlyingCrvUsdReserve);
console2.log("================================================");
}
}
Logs:
Reserve Data: at the beginning
totalLiquidity 2000000000000000000000
totalUsage 0
liquidityIndex 1000000000000000000000000000
usageIndex 1000000000000000000000000000
lastUpdateTimestamp 172801
underlyingCrvUsdReserve 2000000000000000000000
Reserve Data: after first borrowing
totalLiquidity 1000000000000000000000
totalUsage 1000000000000000000000
liquidityIndex 1000000000000000000000000000
usageIndex 1000000000000000000000000000
lastUpdateTimestamp 172801
underlyingCrvUsdReserve 1000000000000000000000
Printing address balance for: borrower
crvUSD balance 1000000000000000000000
Balance of DebtToken 1000000000000000000000
scaledBalance DebtToken 1000000000000000000000
User Scaled Debt Balance in LendingPool:
userScaledDebtBalance 1000000000000000000000
New Usage Index
newUsageIndex 1019886942862361165769120316
Expected New Balance Increase
expectedNewBalanceIncrease 19886942862361165769
Expected Amount To Mint
expectedAmountToMint 1019886942862361165769
Expected Debt Token Scaled Balance After
expectedDebtTokenScaledBalanceAfter 2039773885724722331538
Actual Debt Token Scaled Balance After
actualDebtTokenScaledBalanceAfter 2000387778762321296953
Actual Scaled Debt In Lending Pool
actualScaledDebtInLendingPool 1980500835899960131185
Difference In Debt Token Contract
- differenceInDebtTokenContract 39386106962401034585
Difference In Lending Pool
- differenceInLendingPool 59273049824762200353
Reserve Data: after second borrowing
+ totalLiquidity 0
totalUsage 1961381889200520545996
liquidityIndex 1009845890410958904109589041
usageIndex 1019886942862361165769120316
lastUpdateTimestamp 1036801
underlyingCrvUsdReserve 0
Reserve Data: after repayment
totalLiquidity 2019886942862361165770
- totalUsage 19499164100039868814 // Should be 0 because no Liquidity is used anymore
liquidityIndex 1009845890410958904109589041
usageIndex 1019886942862361165769120316
lastUpdateTimestamp 1036801
underlyingCrvUsdReserve 2019886942862361165770
Printing address balance for: borrower
crvUSD balance 9980113057137638834230
Balance of DebtToken 20282433358771983485
- scaledBalance DebtToken 19886942862361165768
User Scaled Debt Balance in LendingPool:
- userScaledDebtBalance 0
This affects the whole accounting functionality of the protocol including but not limited to:
Handle the scaling in the DebtToken mint/burn function and remove it from the _update function. Use correct balance in the mint function and use the actual scaled balance to store in the user.scaledDebtBalance mapping. Review all of the operations where user.scaledDebtBalance is used.