Core Contracts

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

Users cannot be liquidated if reserveAsset does not match StabilityPool::crvUSD address

Summary

The liquidation process in StabilityPool::liquidateBorrower incorrectly assumes that the reserve asset will always be crvUSD. However, LendingPool::finalizeLiquidation transfers the reserve asset from the Stability Pool to the reserve's RToken contract, meaning the reserve asset could be any stablecoin, including crvUSD but with a different contract address. If the reserve asset is not recognized as the expected crvUSD contract, the Stability Pool will incorrectly check its crvUSD balance, potentially causing the liquidation to revert and preventing borrowers from being liquidated.

Vulnerability Details

The issue arises in the following code snippet from StabilityPool::liquidateBorrower:

uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();

This check assumes that the reserve asset is always the crvUSD token contract that was initially deployed. However, LendingPool::finalizeLiquidation transfers the reserve asset using:

IERC20(reserve.reserveAssetAddress).safeTransferFrom(
msg.sender,
reserve.reserveRTokenAddress,
amountScaled
);

Here, reserve.reserveAssetAddress can be any stablecoin, including crvUSD with a different contract address. If the Stability Pool only checks for a specific crvUSD address rather than dynamically verifying the reserve asset, then any liquidation process involving a different crvUSD deployment or another stablecoin will fail.

If the reserve asset is not crvUSD, the liquidation will fail.The Stability Pool will check its crvUSD balance instead of the actual reserve asset balance, leading to an incorrect InsufficientBalance error. Even if the reserve asset is crvUSD, a different contract address could cause failure.

If the Lending Pool uses a different crvUSD contract instance than what the Stability Pool expects, the liquidation will not recognize the correct balance.

Project intends to reuse the same Lending Pool for other stablecoins

As confirmed by the developers in a private thread:

"We hope to be able to reuse the exact same contract for most other ERC20s (those with custom logic would draw a modified Lending Pool contract to do that, but all the 'classic' ERC20s should fit in the pool)."

"So for the pool, still a crvUSD at launch yes, but the same pool is supposed to be reused for other stables."

This confirms that the same Lending Pool may be used for different stablecoins, and the Stability Pool must support any reserve asset dynamically.

LendingPool constructor allows a flexible reserve asset

The LendingPool::constructor natspec contains:

* @param _reserveAssetAddress The address of the reserve asset (e.g., crvUSD)

This implies that the reserve asset can be another token, including a different crvUSD deployment.

If the same Stability Pool is reused, and it only checks crvUSD by a hardcoded contract reference, then any future stablecoin-based reserves—including a differently deployed crvUSD—will not be liquidatable, leading to stuck positions and bad debt accumulation.

Proof Of Code (POC)
This test was run in protocols-test.js in the "StabilityPool" describe block

it("liquidation not possible if reserveAsset is not crvUSD", async function () {
//c for testing purposes
// Setup stability pool deposit
await contracts.altReserveAsset
.connect(owner)
.approve(contracts.stabilityPool.target, STABILITY_DEPOSIT);
await contracts.altReserveAsset
.connect(owner)
.transfer(contracts.stabilityPool.target, STABILITY_DEPOSIT); //c this is where the stability pool gets the reserveAsset to cover the debt
/*c to get access to the altReserveAsset, go to deploycontracts.js, deploy the altReserveAsset with the following line ABOVE the rtoken and lending pool contract deployments:
//c get new mockerc20 contract to use as reserve asset
const altReserveAsset = await deployContract("RAACMockERC20", [
owner.address,
]);
and in the rToken deployment, add the altReserveAsset.target to the arguments and remove crvUSD.target. Do the same for the lendingPool deployment and modify the above beforeEach hook as follows:
beforeEach(async function () {
await contracts.altReserveAsset
.connect(user1)
.approve(contracts.lendingPool.target, STABILITY_DEPOSIT);
await contracts.lendingPool.connect(user1).deposit(STABILITY_DEPOSIT);
await contracts.rToken
.connect(user1)
.approve(contracts.stabilityPool.target, STABILITY_DEPOSIT);
}); */
// Create position to be liquidated
const newTokenId = HOUSE_TOKEN_ID + 2;
await contracts.housePrices.setHousePrice(newTokenId, HOUSE_PRICE);
await contracts.crvUSD
.connect(user2)
.approve(contracts.nft.target, HOUSE_PRICE);
await contracts.nft.connect(user2).mint(newTokenId, HOUSE_PRICE);
await contracts.nft
.connect(user2)
.approve(contracts.lendingPool.target, newTokenId);
await contracts.lendingPool.connect(user2).depositNFT(newTokenId);
await contracts.lendingPool.connect(user2).borrow(BORROW_AMOUNT);
// Trigger and complete liquidation
await contracts.housePrices.setHousePrice(
newTokenId,
(HOUSE_PRICE * 10n) / 100n
);
await contracts.lendingPool
.connect(user3)
.initiateLiquidation(user2.address);
await time.increase(73 * 60 * 60);
// We need stability pool to have correct reserveAsset amount to cover the debt
const initialBalance = await contracts.altReserveAsset.balanceOf(
contracts.stabilityPool.target
);
//c This will revert when it shouldn't because the Stability Pool should be able to liquidate the borrower with the reserveAsset it has.
await contracts.lendingPool.connect(owner).updateState();
await expect(
contracts.stabilityPool.connect(owner).liquidateBorrower(user2.address)
).to.be.revertedWithCustomError(
contracts.stabilityPool,
"InsufficientBalance"
);
});

Impact

Failed Liquidations: If the reserve asset is not crvUSD or a different crvUSD contract address is used, the Stability Pool will incorrectly check its balance, causing liquidations to revert.

Future Incompatibility: The project plans to support other stablecoins in the same pool, but this bug makes the liquidation mechanism unsuitable for multi-asset reserves.

Tools Used
Manual Review
Hardhat

Recommendations
Dynamic Reserve Asset Retrieval
Instead of assuming crvUSD, the Stability Pool should dynamically check the actual reserve asset from the Lending Pool.

Add a getter function in LendingPool:

function getReserveAsset() external view returns (address) {
return reserve.reserveAssetAddress;
}

Update StabilityPool::liquidateBorrower to check the correct reserve asset:

// Get the actual reserve asset from LendingPool
address reserveAsset = lendingPool.getReserveAsset();

Replace the hardcoded crvUSD balance check with the correct asset balance:

uint256 stabilityPoolBalance = IERC20(reserveAsset).balanceOf(address(this));
if (stabilityPoolBalance < scaledUserDebt) revert InsufficientBalance();

This fix ensures that:

The liquidation process works correctly, regardless of the reserve asset.
Future stablecoins or different crvUSD deployments are properly supported.
The protocol remains resilient and adaptable to future asset changes.

By implementing this fix, the protocol can ensure seamless liquidation across multiple stablecoins, preventing bad debt accumulation and ensuring the long-term viability of the Lending Pool.

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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