Core Contracts

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

Mismatched Scaling in Stability Pool Deposits and Withdrawals Causes Permanent RToken Loss

Summary

The Stability Pool's deposit and withdrawal mechanisms do not correctly account for the scaling effects of getNormalizedIncome(), leading to a critical flaw where users lose RTokens permanently. When depositing, RTokens are scaled down but users still receive deTokens at a 1:1 ratio, meaning they are credited more than they should be. Upon withdrawal, deTokens are burned at the same 1:1 ratio, but due to the liquidity index increasing over time, fewer RTokens are effectively returned to the user than initially deposited. This discrepancy results in trapped RTokens in the Stability Pool that cannot be withdrawn, leading to a continuous accumulation of excess funds.

Vulnerability Details

Issue 1: Deposited RTokens Are Scaled Down, But deTokens Are Minted 1:1

When a user deposits RTokens into the Stability Pool, they receive an equivalent amount of deTokens (1:1 exchange rate).

However, due to the overridden _update() function in RToken.sol, the actual amount of RTokens received by the contract is scaled down based on getNormalizedIncome().

RToken.sol#L301-L311

/**
* @dev Internal function to handle token transfers, mints, and burns
* @param from The sender address
* @param to The recipient address
* @param amount The amount of tokens
*/
function _update(address from, address to, uint256 amount) internal override {
// Scale amount by normalized income for all operations (mint, burn, transfer)
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
super._update(from, to, scaledAmount);
}

The user is effectively credited more deTokens than the scaled-down RTokens they actually contributed to StabilityPool.sol, leading to an imbalance between the two token supplies.

Example Scenario:

  1. User deposits 1000 RTokens and expects to receive 1000 deTokens.

  2. Assuming getNormalizedIncome() == 1.1, only 1000/1.1 = 909 RTokens are actually received by the Stability Pool.

  3. User still receives 1000 deTokens, creating an over-crediting issue.

Issue 2: Withdrawals Transfer More RTokens Than the User Initially Deposited

Over time, reserve.liquidityIndex increases due to linearly accrued interest.

When a user later withdraws by burning their deTokens, the contract transfers RTokens at a 1:1 ratio, but since the liquidity index has increased, the RTokens they receive are now worth more than when they initially deposited.

If the withdrawal logic does not properly account for this increase in value, the user gets fewer RTokens than they initially deposited. This leads to leftover RTokens accumulating in the Stability Pool that cannot be withdrawn, since the user has already burned all their deTokens.

Example Scenario:

  1. User burns 1000 deTokens to withdraw RTokens.

  2. The contract transfers 1000 RTokens, but due to interest accrual, they are worth more than the originally deposited RTokens. Assuming getNormalizedIncome() == 1.3, only 1000/1.3 = 769 RTokens will be transferred back to the user even though they have deposited less, i.e. 909 instead of 1000 tokens.

  3. The discrepancy results in "leftover", i.e. 909 - 769 = 140 RTokens in the Stability Pool that no user can withdraw, leading to trapped liquidity.

Impact

deTokens are over-credited upon deposit incurring static shift which has nonetheless kept the rewarding logic intact. Nevertheless, it belies the very fact that users cannot reclaim the full amount of their originally deposited RTokens, leading to permanent losses. In the end, the Stability Pool accumulates excess RTokens that can never be withdrawn, causing inefficiencies in the system.

The entire issues is circumvented only when deposit() and withdraw() are carried out atomically where the over-credited 1000 value is scaled down perfectly to 909 again while both transfers are referencing the same reserve.liquidityIndex. (Note: This will greatly exacerbate the Vampire Attack on draining the currently unweighted and checkpoint free RAAC rewards distribution that I have reported separately.)

Tools Used

Manual

Recommendations

Consider implementing the following refactoring:

StabilityPool.sol#L174-L184

function deposit(uint256 amount) external nonReentrant whenNotPaused validAmount(amount) {
_update();
+ // Update reserve state to get the most accurate normalized income
+ lendingPool.updateState();
rToken.safeTransferFrom(msg.sender, address(this), amount);
+ // Compute the actual RToken amount received, considering scaling
+ uint256 actualReceived = amount.rayDiv(lendingPool.getNormalizedIncome());
- uint256 deCRVUSDAmount = calculateDeCRVUSDAmount(amount);
+ uint256 deCRVUSDAmount = calculateDeCRVUSDAmount(actualReceived); // Mint deTokens based on the actual received RTokens
deToken.mint(msg.sender, deCRVUSDAmount);
- userDeposits[msg.sender] += amount;
+ userDeposits[msg.sender] += actualReceived; // For proper account tallying
- _mintRAACRewards(); // May be removed since it has earlier been taken care of by _update()
emit Deposit(msg.sender, amount, deCRVUSDAmount);
}

StabilityPool.sol#L237-L238

+ lendingPool.updateState();
deToken.burn(msg.sender, deCRVUSDAmount);
- rToken.safeTransfer(msg.sender, rcrvUSDAmount);
+ rToken.safeTransfer(msg.sender, rcrvUSDAmount.rayMul(lendingPool.getNormalizedIncome()));

Note: The above fixes are based on the assumption that the issue on double scaling down associated with RToken transfers has been resolved that I have separately reported.

Note: This also underscores the need of having the state updated prior to making any transfer as I have also reported separately. Without the latest state update, users end up transferring more RTokens than intended to the contract. The excess amount of RTokens could have been used/leveraged elsewhere.

Updates

Lead Judging Commences

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

StabilityPool's userDeposits mapping doesn't update with DEToken transfers or interest accrual, and this combined with RToken transfers causes fund loss and permanent lockup

Support

FAQs

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