Core Contracts

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

`StabilityPool`'s 1:1 exchange rate between `rToken` and `deToken` causes yield to be permanently stuck and potential insolvency

Summary

The StabilityPool implements a fixed 1:1 exchange rate between rToken (interest-bearing) and deToken, causing yield to be permanently stuck in the contract. Additionally, liquidations could lead to contract insolvency as rTokens are withdrawn without accounting for user deposits.

Vulnerability Details

The StabilityPool has two critical issues in its exchange rate implementation:

  1. In withdraw(), the contract burns deTokens and transfers rTokens at a 1:1 ratio, ignoring that rToken is interest-bearing:

deToken.burn(msg.sender, deCRVUSDAmount);
rToken.safeTransfer(msg.sender, rcrvUSDAmount);
  1. During liquidations, rTokens are withdrawn from the lending pool to repay borrower debt without accounting for user deposits, potentially leading to insolvency. Please note that this second issue is a guess from my side, as the code is not clear about it. The liquidateBorrower() function is not clear about how it gets the crvUSD to repay the debt. There is no way to deposit or withdraw crvUSD from the StabilityPool contract. The only way to get crvUSD is to withdraw the rToken from the LendingPool contract. Which also would give purpose to the deposited rToken in the StabilityPool contract by users. The other option would be that some external actor just transfers crvUSD to the StabilityPool contract like it is done in the tests. However, this does not make sense as this actor would not get any benefit from it.

The getExchangeRate() function has a commented out implementation that would track the proper exchange rate, but currently just returns 1e18:

function getExchangeRate() public view returns (uint256) {
// uint256 totalDeCRVUSD = deToken.totalSupply();
// uint256 totalRcrvUSD = rToken.balanceOf(address(this));
// if (totalDeCRVUSD == 0 || totalRcrvUSD == 0) return 10**18;
// uint256 scalingFactor = 10**(18 + deTokenDecimals - rTokenDecimals);
// return (totalRcrvUSD * scalingFactor) / totalDeCRVUSD;
return 1e18;
}

Impact

  1. Permanent loss of yield: When users withdraw, they only receive their initial deposit amount in rTokens, while the accrued interest remains stuck in the contract forever.

  2. Potential insolvency: During liquidations, rTokens could be withdrawn to repay debt, reducing the contract's rToken balance below user deposits. This can lead to later withdrawals failing due to insufficient funds.

Example scenario for yield loss:

  1. User A deposits 100 rTokens (worth 100 crvUSD) and receives 100 deTokens

  2. After time T, due to yield, the 100 rTokens are now worth 110 crvUSD

  3. User A withdraws with 100 deTokens

  4. They only receive 100 rTokens (worth 100 crvUSD)

  5. The 10 crvUSD worth of yield is permanently stuck in the contract

Tools Used

Manual review

Proof of Concept

First a bug need to be fixed in the RToken contract to make sure the correct amount of rTokens is transferred, otherwise there will be a dust left in the contract which is not caused by the yield. The transfer() function is scaling the amount down twice, once directly and the other in the _update() function.

function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
- uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
- return super.transfer(recipient, scaledAmount);
+ return super.transfer(recipient, amount);
}

Add the following test case to the test/e2e/protocols-tests.js file in the StabilityPool section:

it('should demonstrate yield getting stuck in contract', async function () {
const user1InitialrTokenBalance = await contracts.rToken.balanceOf(user1.address);
const stabilityPoolInitialrTokenBalance = await contracts.rToken.balanceOf(contracts.stabilityPool.target);
// Setup stability pool deposit
await contracts.stabilityPool.connect(user1).deposit(user1InitialrTokenBalance);
const user1deTokenBalanceAfterDeposit = await contracts.deToken.balanceOf(user1.address);
expect(user1deTokenBalanceAfterDeposit).to.be.eq(user1InitialrTokenBalance);
const afterDepositStabilityPoolBalance = await contracts.rToken.balanceOf(contracts.stabilityPool.target);
// Create position to be generate interest in lending pool
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);
await time.increase(73 * 60 * 60);
await contracts.lendingPool.updateState();
// User1 withdraws deToken
const user1deTokenBalance = await contracts.deToken.balanceOf(user1.address);
await contracts.stabilityPool.connect(user1).withdraw(user1deTokenBalance);
const user1rTokenBalance = await contracts.rToken.balanceOf(user1.address);
const finalStabilityPoolBalance = await contracts.rToken.balanceOf(contracts.stabilityPool.target);
const user1deTokenBalanceAfterWithdraw = await contracts.deToken.balanceOf(user1.address);
// User1 has the same rToken balance as before the deposit
expect(user1rTokenBalance).to.be.equal(user1InitialrTokenBalance);
// Stability pool has more rToken than after the deposit and withdraw. Kept the interest generated in the lending pool stuck in the contract
expect(finalStabilityPoolBalance).to.be.gt(stabilityPoolInitialrTokenBalance);
});

Recommendations

  1. Implement proper exchange rate tracking:

  • Enable the commented out code in getExchangeRate()

  • Update exchange rate calculations during deposits/withdrawals

  • Consider making deToken interest-bearing to match rToken growth

  1. For liquidations:

  • Track rToken withdrawals during liquidations

  • Implement a mechanism to fairly distribute losses among depositors

  • Consider requiring overcollateralization to protect against insolvency

Updates

Lead Judging Commences

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

StabilityPool::getExchangeRate hardcodes 1:1 ratio instead of calculating real rate, enabling unlimited deToken minting against limited reserves

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

StabilityPool::getExchangeRate hardcodes 1:1 ratio instead of calculating real rate, enabling unlimited deToken minting against limited reserves

Support

FAQs

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