Core Contracts

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

liquidateBorrower scales user's debt twice

Bug description

When liquidateBorrower is called, it gets the user's debt from the lending pool via a call to getUserDebt.

LendingPool.sol#L579-L582

function getUserDebt(address userAddress) public view returns (uint256) {
UserData storage user = userData[userAddress];
return user.scaledDebtBalance.rayMul(reserve.usageIndex);
}

As can be seen, because user's debt is multiplied by usageIndex, it includes interest accrued to it.

However, liquidateBorrower() scales the obtained value by usageIndex again.

StabilityPool.sol#L452-L458

uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(
userDebt,
lendingPool.getNormalizedDebt()
);
if (userDebt == 0) revert InvalidAmount();
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();

And since the function requires that the balance must have at least scaledUserDebt, which has interest accrued twice to it, it will require more funds than necessary to liquidate a position. And because finalizeLiquidation() will only transfer out the actual debt, it will leave the excess in the stability pool without a way to retrieve it.

LendingPool.sol#L508-L525

uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex); <----@ debt with interest accrued to it only once
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
// Transfer NFTs to Stability Pool
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
user.depositedNFTs[tokenId] = false;
raacNFT.transferFrom(address(this), stabilityPool, tokenId);
}
delete user.nftTokenIds;
// Burn DebtTokens from the user
(
uint256 amountScaled, <----@ debt with interest accrued only once
uint256 newTotalSupply,
uint256 amountBurned,
uint256 balanceIncrease
) = IDebtToken(reserve.reserveDebtTokenAddress).burn(
userAddress,
userDebt,
reserve.usageIndex
);
// Transfer reserve assets from Stability Pool to cover the debt
IERC20(reserve.reserveAssetAddress).safeTransferFrom(
msg.sender,
reserve.reserveRTokenAddress,
amountScaled <----@ actual debt with interest accrued only once is transferred out
);

Impact

liquidateBorrower requires more funds the necessary to liquidate a position, leaving excess on the balance of the stability pool without a way to retrieve it.

Proof of Concept

Please add import "hardhat/console.sol"; to the top of the StabilityPool.sol and LendingPool.sol.

Modify lending pool's finalizeLiquidation() function.

LendingPool.sol#L522-L523

(
uint256 amountScaled,
uint256 newTotalSupply,
uint256 amountBurned,
uint256 balanceIncrease
) = IDebtToken(reserve.reserveDebtTokenAddress).burn(
userAddress,
userDebt,
reserve.usageIndex
);
+ console.log("Lending pool debt: ", amountScaled);

Please modify stability pool's liquidateBorrower() function.

StabilityPool.sol#L452-L454

+ lendingPool.updateState(); // to fix another bug
uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(
userDebt,
lendingPool.getNormalizedDebt()
);
+ console.log("stabilityPool debt: ", scaledUserDebt);

Please add this test to StabilityPool.test.js and run it with npx hardhat test --grep "liquidateBorrower scales debt twice".

describe("sl1", function () {
it("liquidateBorrower scales debt twice", async () => {
await raacHousePrices.setHousePrice(1, ethers.parseEther("1000"));
await ethers.provider.send("evm_mine", []);
const tokenId = 1;
const amountToPay = ethers.parseEther("1000");
await crvusd.mint(user1.address, amountToPay);
await crvusd.connect(user1).approve(raacNFT.target, amountToPay);
await raacNFT.connect(user1).mint(tokenId, amountToPay);
await raacNFT.connect(user1).approve(lendingPool.target, tokenId);
await lendingPool.connect(user1).depositNFT(tokenId);
await lendingPool.connect(user1).borrow(ethers.parseEther("1000"));
await lendingPool.initiateLiquidation(user1.address);
// Advance time beyond grace period (72 hours)
await ethers.provider.send("evm_increaseTime", [72 * 60 * 60 + 1]);
await ethers.provider.send("evm_mine");
await crvusd.mint(
await stabilityPool.getAddress(),
ethers.parseEther("1001")
);
await stabilityPool.liquidateBorrower(user1.address);
// await expect(stabilityPool.liquidateBorrower(user1.address)).to.be
// .reverted;
});
};

The console output of the test:

// debt calculated in stability pool liquidateBorrower function
stabilityPool debt: 1000925088732468965074
// actual debt transferred out from stability pool
Lending pool debt: 1000462437442040582019

As can be seen, debt calculated in stability pool is greater than the actual debt in lending pool.

Recommended Mitigation

Do not scale the debt second time in StabilityPool::liquidateBorrower()

StabilityPool.sol#L452-L453

uint256 userDebt = lendingPool.getUserDebt(userAddress);
- uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
Updates

Lead Judging Commences

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

StabilityPool::liquidateBorrower double-scales debt by multiplying already-scaled userDebt with usage index again, causing liquidations to fail

Support

FAQs

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