Core Contracts

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

Incorrect debt calculation due to missing state update leads to failed liquidations

Summary

The StabilityPool::liquidateBorrower() function fails to update the LendingPool state before fetching user debt, resulting in incorrect debt calculations and failed liquidations.

Vulnerability Details

In StabilityPool::liquidateBorrower(), the user's debt is fetched from the LendingPool before updating the pool's state:

function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
// Get user's debt before updating pool state
uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
// ... approve and liquidate logic ...
// State update happens too late
lendingPool.updateState();
lendingPool.finalizeLiquidation(userAddress);
}

The root cause is that lendingPool.updateState() is called after fetching the user's debt. This means the usage index and normalized debt values used in the debt calculation are stale, leading to incorrect debt amounts being calculated.

Proof of Concept

Add the following test to the StabilityPool.test.js file:

describe("Failing liquidation", function () {
beforeEach(async function () { // Setup for the test
const depositAmount1 = ethers.parseEther("100");
const depositAmount2 = ethers.parseEther("100");
// Setup for user1 - Liquidity provider in the Lending Pool
await crvusd.mint(user1.address, depositAmount1);
await crvusd.connect(user1).approve(lendingPool.target, depositAmount1);
await lendingPool.connect(user1).deposit(depositAmount1);
await rToken.connect(user1).approve(stabilityPool.target, depositAmount1);
await stabilityPool.connect(user1).deposit(depositAmount1);
// Set the house price to enable NFT minting
await raacHousePrices.setHousePrice(1, ethers.parseEther("50"));
// Setup for user2 - borrower
await crvusd.mint(user2.address, depositAmount2);
await crvusd.connect(user2).approve(raacNFT.target, depositAmount2);
await raacNFT.connect(user2).mint(1, ethers.parseEther("50")); // Mint NFT to use as collateral
await raacNFT.connect(user2).approve(lendingPool.target, 1);
await lendingPool.connect(user2).depositNFT(1); // Provide the NFT as collateral
// Mint crvUSD to the stability pool so we can repay the user's debt
await crvusd.mint(stabilityPool.target, depositAmount1);
});
it("should fail liquidation", async function () {
// Borrow against the NFT
await lendingPool.connect(user2).borrow(ethers.parseEther("40")); // 80% of the collateral
// Set the house price to a lower value, so we can liquidate the borrower
await raacHousePrices.setHousePrice(1, ethers.parseEther("10"));
// Initiate liquidation - it updates the reserves
await lendingPool.connect(user1).initiateLiquidation(user2.address);
expect(await lendingPool.isUnderLiquidation(user2.address)).to.be.true;
// Skip the grace period
await ethers.provider.send("evm_increaseTime", [72 * 60 * 60 + 1]);
await ethers.provider.send("evm_mine");
// Get the user's debt before the liquidation
const userDebtBeforeUpdateState = await lendingPool.getUserDebt(user2.address);
// Try to finalize the liquidation - it will revert with ERC20InsufficientAllowance
await expect(stabilityPool.liquidateBorrower(user2.address)).to.be.revertedWithCustomError(crvusd, "ERC20InsufficientAllowance");
// Update the state of the Lending Pool
await lendingPool.updateState();
const userDebtAfterUpdateState = await lendingPool.getUserDebt(user2.address);
// No time has passed between the two debt calculations, so the debt should be the same
// Validate that after the update the debt is bigger than before the update, that is why the liquidation fails!
expect(userDebtAfterUpdateState).to.be.gt(userDebtBeforeUpdateState);
});
});

Impact

  • Liquidations will fail due to insufficient approval amounts

  • System cannot properly liquidate positions

Recommendations

Update state before debt calculation:

function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
+ lendingPool.updateState();
uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
// ... rest of function
- lendingPool.updateState();
lendingPool.finalizeLiquidation(userAddress);
}
Updates

Lead Judging Commences

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

StabilityPool: liquidateBorrower should call lendingPool.updateState earlier, to ensure the updated usageIndex is used in calculating the scaledUserDebt

Support

FAQs

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